ravensight-playtest 0.7.0 → 0.7.2
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 +41 -2
- package/package.json +1 -1
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +33 -8
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +2 -0
- package/src/commands/hosted.js +143 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/recordedRun.js +132 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/drivers/web.js +26 -3
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/personaLoop.js +255 -59
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/usage.js +54 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which failed actions are the game's state and which are the runner's.
|
|
3
|
+
*
|
|
4
|
+
* A web page that shows a control as disabled, hidden or not editable has
|
|
5
|
+
* refused the action itself: the click failing is what a player would see.
|
|
6
|
+
* In a real Lab Ledger run the "Run repeat panel" button stayed disabled
|
|
7
|
+
* after the page said it was ready, and the blocker the persona filed was
|
|
8
|
+
* dropped as "driver errors only" because Playwright's click timed out on
|
|
9
|
+
* a button it had found and that said `disabled`. That is evidence.
|
|
10
|
+
*
|
|
11
|
+
* A failure where the runner itself broke stays a driver error: a target
|
|
12
|
+
* that never resolved (a selector or ref problem), a browser or page that
|
|
13
|
+
* closed or crashed under the runner.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Playwright's actionability waits that name the game's state. */
|
|
17
|
+
const NOT_ACTIONABLE = /element is not (enabled|editable|visible)/i;
|
|
18
|
+
|
|
19
|
+
/** The element a locator resolved to, from Playwright's call log. */
|
|
20
|
+
const RESOLVED = /locator resolved to (<[^\n]*)/i;
|
|
21
|
+
|
|
22
|
+
/** A resolved element whose own markup says it is disabled. */
|
|
23
|
+
const RESOLVED_DISABLED = /locator resolved to <[^>]*\s(?:disabled(?:[\s=>/]|$)|aria-disabled="true")/i;
|
|
24
|
+
|
|
25
|
+
/** The runner's browser, context or page went away under it. */
|
|
26
|
+
const RUNNER_GONE = /Target (?:page, context or browser has been closed|crashed)|browser has (?:been )?disconnected|Browser closed|Page crashed/i;
|
|
27
|
+
|
|
28
|
+
const STATE_WORDS = { enabled: 'disabled', editable: 'not editable', visible: 'hidden' };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The game's refusal named in a failed action's error, or null when the
|
|
32
|
+
* failure is not one.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text the driver's error text (a call log may be cut short)
|
|
35
|
+
* @returns {{state: string, element: string|null}|null}
|
|
36
|
+
*/
|
|
37
|
+
export function gameRefusal(text) {
|
|
38
|
+
const value = String(text || '');
|
|
39
|
+
if (!value || RUNNER_GONE.test(value)) return null;
|
|
40
|
+
const resolved = value.match(RESOLVED);
|
|
41
|
+
if (!resolved) return null;
|
|
42
|
+
const element = resolved[1].trim().slice(0, 160);
|
|
43
|
+
const waited = value.match(NOT_ACTIONABLE);
|
|
44
|
+
if (waited) return { state: STATE_WORDS[waited[1].toLowerCase()], element };
|
|
45
|
+
if (RESOLVED_DISABLED.test(value)) return { state: 'disabled', element };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when the error says the runner's browser or page went away.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function runnerGone(text) {
|
|
56
|
+
return RUNNER_GONE.test(String(text || ''));
|
|
57
|
+
}
|
package/src/run/personaLoop.js
CHANGED
|
@@ -6,12 +6,14 @@ import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages
|
|
|
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 {
|
|
9
|
+
import { reserveDecision } from './budgetReserve.js';
|
|
10
|
+
import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, stripTrailingToolMarkup, writeReportFiles } from './report.js';
|
|
10
11
|
import { assertNoSecrets, scanForSecrets } from './secretScan.js';
|
|
11
12
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
13
|
import { createTranscript } from './transcript.js';
|
|
13
14
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
14
15
|
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
16
|
+
import { gameRefusal } from './gameRefusal.js';
|
|
15
17
|
import { BUILT_IN_NOISE, expectedNoiseForPrompt, isExpectedNoise } from './noise.js';
|
|
16
18
|
import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
|
|
17
19
|
|
|
@@ -102,6 +104,31 @@ export const ACT_MAX_TOKENS = 1024;
|
|
|
102
104
|
/** Token ceiling asked for when writing the report. */
|
|
103
105
|
export const REPORT_MAX_TOKENS = 4096;
|
|
104
106
|
|
|
107
|
+
/** The most characters of a persona's closing message kept as its quit detail. */
|
|
108
|
+
export const MAX_QUIT_DETAIL_CHARS = 160;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A persona's closing message as a one-line quit detail: its first sentence,
|
|
112
|
+
* without markdown, cut to MAX_QUIT_DETAIL_CHARS. A message that is already
|
|
113
|
+
* report text says only that the persona stopped.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
export function personaQuitDetail(text) {
|
|
119
|
+
const plain = 'the persona stopped playing';
|
|
120
|
+
const first = String(text || '').split(/\n/).map(line => line.replace(/[*_`#>]+/g, '').trim()).find(Boolean) || '';
|
|
121
|
+
if (!first || /report\.(md|json)|write_file|\b(?:writ(?:e|ing)|start(?:ing)? on) (?:the |my )?(?:final )?report\b/i.test(first)) return plain;
|
|
122
|
+
const sentence = (first.match(/^.*?[.!?](?=\s|$)/) || [first])[0];
|
|
123
|
+
if (sentence.length <= MAX_QUIT_DETAIL_CHARS) return sentence;
|
|
124
|
+
const cut = sentence.slice(0, MAX_QUIT_DETAIL_CHARS - 3);
|
|
125
|
+
const space = cut.lastIndexOf(' ');
|
|
126
|
+
return `${space > 40 ? cut.slice(0, space) : cut}...`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The stop detail when play ends to keep the report's budget. */
|
|
130
|
+
export const RESERVE_STOP_DETAIL = 'stopped early so the rest of the model budget pays for the report';
|
|
131
|
+
|
|
105
132
|
/**
|
|
106
133
|
* @typedef {Object} PersonaRunOutcome
|
|
107
134
|
* @property {string} state one of succeeded, failed, canceled, budget_exceeded
|
|
@@ -373,6 +400,11 @@ async function playLoop({
|
|
|
373
400
|
heartbeat,
|
|
374
401
|
onProgress = () => {},
|
|
375
402
|
onBudgetWarning = null,
|
|
403
|
+
// The run's model budget in USD, from register (`limits.model_usd`). With it
|
|
404
|
+
// the loop stops early enough that the report still fits; without it (an
|
|
405
|
+
// older server) only the server's 80 percent warning does.
|
|
406
|
+
modelBudgetUsd = null,
|
|
407
|
+
reportSkill = null,
|
|
376
408
|
log = () => {}
|
|
377
409
|
}) {
|
|
378
410
|
const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
|
|
@@ -406,9 +438,39 @@ async function playLoop({
|
|
|
406
438
|
let budgetWarned = false;
|
|
407
439
|
let budgetExceeded = false;
|
|
408
440
|
let reportBudgetAvailable = false;
|
|
441
|
+
let reserveStopped = false;
|
|
409
442
|
let canceled = false;
|
|
410
443
|
let unchanged = 0;
|
|
411
444
|
const maxTurns = maxActions * 3 + 20;
|
|
445
|
+
// What the last few act turns really cost, in weighted units.
|
|
446
|
+
const recentActUnits = [];
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Whether the next act turn would leave too little for the report. Sized
|
|
450
|
+
* from the act request about to be sent and the report request as it would
|
|
451
|
+
* be built now; see ./budgetReserve.js.
|
|
452
|
+
*/
|
|
453
|
+
const reserveCheck = () => {
|
|
454
|
+
const spend = modelBudgetUsd ? ledger.spendEstimate() : null;
|
|
455
|
+
if (!spend) return { stop: false, known: false, figures: {} };
|
|
456
|
+
const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: imageBudget, keepObservations }));
|
|
457
|
+
const actChars = JSON.stringify({ messages: prepared, max_tokens: ACT_MAX_TOKENS, stream: true, system, tools: playTools }).length;
|
|
458
|
+
const reportRequest = buildReportRequest({
|
|
459
|
+
skill: reportSkill || skill, persona, brief, localBrief, intentNotes, driverName, target, runId, jobId,
|
|
460
|
+
play: { actionsTaken: actions, quitReason: 'budget_exhausted', quitDetail: RESERVE_STOP_DETAIL, actionLog: actionLogFrom(actionRecords) },
|
|
461
|
+
notes, observations, screenshots
|
|
462
|
+
});
|
|
463
|
+
const reportChars = JSON.stringify({ messages: reportRequest.messages, max_tokens: REPORT_MAX_TOKENS, stream: true, system: reportRequest.system, tools: REPORT_TOOLS }).length;
|
|
464
|
+
return reserveDecision({
|
|
465
|
+
usdMax: modelBudgetUsd,
|
|
466
|
+
spend,
|
|
467
|
+
actChars,
|
|
468
|
+
actMaxTokens: ACT_MAX_TOKENS,
|
|
469
|
+
actCostUnits: recentActUnits.length > 0 ? Math.max(...recentActUnits) : 0,
|
|
470
|
+
reportChars,
|
|
471
|
+
reportMaxTokens: REPORT_MAX_TOKENS
|
|
472
|
+
});
|
|
473
|
+
};
|
|
412
474
|
|
|
413
475
|
// Notes waiting for the next observation, which is the screen they are
|
|
414
476
|
// checked against: a failure the next screen does not show is not a
|
|
@@ -454,6 +516,17 @@ async function playLoop({
|
|
|
454
516
|
quitDetail = 'the job was cancelled';
|
|
455
517
|
break;
|
|
456
518
|
}
|
|
519
|
+
if (turns > 0) {
|
|
520
|
+
const reserve = reserveCheck();
|
|
521
|
+
if (reserve.stop) {
|
|
522
|
+
reserveStopped = true;
|
|
523
|
+
quitReason = 'budget_exhausted';
|
|
524
|
+
quitDetail = RESERVE_STOP_DETAIL;
|
|
525
|
+
transcript.write({ kind: 'budget_reserve_stop', ...reserve.figures });
|
|
526
|
+
log(`stopping play after ${actions} action(s) so the rest of the model budget pays for the report (spent about $${reserve.figures.usd_spent} of $${reserve.figures.usd_max}; the report needs about $${reserve.figures.report_reserve_usd}).`);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
457
530
|
turns += 1;
|
|
458
531
|
let answer;
|
|
459
532
|
const askedAt = Date.now();
|
|
@@ -491,6 +564,8 @@ async function playLoop({
|
|
|
491
564
|
}
|
|
492
565
|
|
|
493
566
|
ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
567
|
+
recentActUnits.push(ledger.lastCallUnits || 0);
|
|
568
|
+
if (recentActUnits.length > 3) recentActUnits.shift();
|
|
494
569
|
// The warn turn is a turn the developer paid for, so its tool calls are
|
|
495
570
|
// executed like any other and the wrap-up notice rides along on that
|
|
496
571
|
// turn's tool_result. Two things this avoids: discarding a paid action,
|
|
@@ -513,7 +588,12 @@ async function playLoop({
|
|
|
513
588
|
// No tool call is how a persona stops: there is no quit tool in the
|
|
514
589
|
// step's allowlist, and adding one would be refused by the proxy.
|
|
515
590
|
quitReason = warnedNow ? 'budget_exhausted' : 'persona_quit';
|
|
516
|
-
|
|
591
|
+
// The whole closing message is in the transcript. The detail is one
|
|
592
|
+
// short line: it becomes report.json's quit_detail, and a closing turn
|
|
593
|
+
// is often the persona starting on its report ("Now writing the final
|
|
594
|
+
// report. **report.md:** ..."), which in 0.7.1 ended up as a
|
|
595
|
+
// succeeded run's reason on the dashboard.
|
|
596
|
+
quitDetail = personaQuitDetail(answer.text);
|
|
517
597
|
transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
|
|
518
598
|
break;
|
|
519
599
|
}
|
|
@@ -695,6 +775,7 @@ async function playLoop({
|
|
|
695
775
|
budgetWarned,
|
|
696
776
|
budgetExceeded,
|
|
697
777
|
reportBudgetAvailable,
|
|
778
|
+
reserveStopped,
|
|
698
779
|
canceled,
|
|
699
780
|
imageBudget,
|
|
700
781
|
finalScreenshot: final ? final.relativePath : null,
|
|
@@ -733,55 +814,7 @@ async function reportPhase({
|
|
|
733
814
|
// The report step never sees the test account: it has nothing to sign in
|
|
734
815
|
// to, and the password crosses the model proxy during play only.
|
|
735
816
|
const secrets = secretsOf(localBrief);
|
|
736
|
-
const
|
|
737
|
-
const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
|
|
738
|
-
const messages = [{
|
|
739
|
-
role: 'user',
|
|
740
|
-
content: [{
|
|
741
|
-
type: 'text',
|
|
742
|
-
text: [
|
|
743
|
-
`The session is over. Write report.md and report.json for run ${runId} of job ${jobId}.`,
|
|
744
|
-
`persona: ${persona.slug} driver: ${driverName} target: ${target}`,
|
|
745
|
-
`actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
|
|
746
|
-
'',
|
|
747
|
-
'Notes you logged, in order:',
|
|
748
|
-
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
749
|
-
'',
|
|
750
|
-
'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.',
|
|
751
|
-
'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.',
|
|
752
|
-
`Notes marked expected noise match the brief's expected_noise or the built in rule (${BUILT_IN_NOISE}); they are never a finding of any severity, and the runner drops a finding whose transcript evidence is only expected noise.`,
|
|
753
|
-
'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.',
|
|
754
|
-
'',
|
|
755
|
-
'Observations the persona was shown, by index:',
|
|
756
|
-
observationLines(observations),
|
|
757
|
-
'',
|
|
758
|
-
'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
|
|
759
|
-
actionLogLines(play.actionLog),
|
|
760
|
-
'',
|
|
761
|
-
'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.',
|
|
762
|
-
'',
|
|
763
|
-
'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):',
|
|
764
|
-
networkNoteLines(notes),
|
|
765
|
-
'',
|
|
766
|
-
'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"}.',
|
|
767
|
-
'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. After you write the report, the runner shows each blocker or major (at most three) its cited screenshot and downgrades one step any finding the image contradicts.',
|
|
768
|
-
'',
|
|
769
|
-
'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".',
|
|
770
|
-
'',
|
|
771
|
-
'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.',
|
|
772
|
-
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
773
|
-
'',
|
|
774
|
-
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
775
|
-
screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
|
|
776
|
-
'',
|
|
777
|
-
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
778
|
-
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
779
|
-
].join('\n')
|
|
780
|
-
}]
|
|
781
|
-
}];
|
|
782
|
-
// The prompt is built from notes, observations and the action log; none
|
|
783
|
-
// of them may carry the test account password into the report step.
|
|
784
|
-
messages[0].content[0].text = scrubSecrets(messages[0].content[0].text, secrets);
|
|
817
|
+
const { system, messages } = buildReportRequest({ skill, persona, brief, localBrief, intentNotes, driverName, target, runId, jobId, play, notes, observations, screenshots });
|
|
785
818
|
|
|
786
819
|
let report = null;
|
|
787
820
|
let markdown = null;
|
|
@@ -808,7 +841,7 @@ async function reportPhase({
|
|
|
808
841
|
// never escalates to a dearer tier after a warning, and a 402 on a report
|
|
809
842
|
// turn gets one retry with the output ceiling cut to what the refusal says
|
|
810
843
|
// still fits.
|
|
811
|
-
const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
|
|
844
|
+
const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded && !play.reserveStopped;
|
|
812
845
|
let maxTokens = REPORT_MAX_TOKENS;
|
|
813
846
|
let shrunk = false;
|
|
814
847
|
let lastCandidate = null;
|
|
@@ -868,7 +901,9 @@ async function reportPhase({
|
|
|
868
901
|
if (toolUse.name !== 'write_file' || toolUse.malformed) continue;
|
|
869
902
|
const name = String(toolUse.input.path || '');
|
|
870
903
|
if (name !== RUN_FILES.reportMd && name !== RUN_FILES.reportJson) continue;
|
|
871
|
-
|
|
904
|
+
// A model sometimes closes its tool call inside the file's content;
|
|
905
|
+
// the stray tags come off before the file is validated or written.
|
|
906
|
+
files.set(name, stripTrailingToolMarkup(String(toolUse.input.content || '')));
|
|
872
907
|
}
|
|
873
908
|
if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
|
|
874
909
|
if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
|
|
@@ -1168,6 +1203,60 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
|
|
|
1168
1203
|
return { report: { ...report, findings }, downgraded };
|
|
1169
1204
|
}
|
|
1170
1205
|
|
|
1206
|
+
/**
|
|
1207
|
+
* The report step's request: its system prompt and its one user message. Built
|
|
1208
|
+
* the same way for the call and for sizing it during play (./budgetReserve.js),
|
|
1209
|
+
* so the budget kept back for the report is the report's own size.
|
|
1210
|
+
*
|
|
1211
|
+
* @returns {{system: Array, messages: Array}}
|
|
1212
|
+
*/
|
|
1213
|
+
export function buildReportRequest({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, runId, jobId, play, notes, observations = [], screenshots }) {
|
|
1214
|
+
const secrets = secretsOf(localBrief);
|
|
1215
|
+
const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
|
|
1216
|
+
const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
|
|
1217
|
+
const text = [
|
|
1218
|
+
`The session is over. Write report.md and report.json for run ${runId} of job ${jobId}.`,
|
|
1219
|
+
`persona: ${persona.slug} driver: ${driverName} target: ${target}`,
|
|
1220
|
+
`actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
|
|
1221
|
+
'',
|
|
1222
|
+
'Notes you logged, in order:',
|
|
1223
|
+
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
1224
|
+
'',
|
|
1225
|
+
'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.',
|
|
1226
|
+
'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.',
|
|
1227
|
+
`Notes marked expected noise match the brief's expected_noise or the built in rule (${BUILT_IN_NOISE}); they are never a finding of any severity, and the runner drops a finding whose transcript evidence is only expected noise.`,
|
|
1228
|
+
'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.',
|
|
1229
|
+
'',
|
|
1230
|
+
'Observations the persona was shown, by index:',
|
|
1231
|
+
observationLines(observations),
|
|
1232
|
+
'',
|
|
1233
|
+
'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
|
|
1234
|
+
actionLogLines(play.actionLog),
|
|
1235
|
+
'',
|
|
1236
|
+
'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.',
|
|
1237
|
+
'',
|
|
1238
|
+
'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):',
|
|
1239
|
+
networkNoteLines(notes),
|
|
1240
|
+
'',
|
|
1241
|
+
'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"}.',
|
|
1242
|
+
'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. After you write the report, the runner shows each blocker or major (at most three) its cited screenshot and downgrades one step any finding the image contradicts.',
|
|
1243
|
+
'',
|
|
1244
|
+
'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".',
|
|
1245
|
+
'',
|
|
1246
|
+
'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.',
|
|
1247
|
+
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
1248
|
+
'',
|
|
1249
|
+
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
1250
|
+
screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
|
|
1251
|
+
'',
|
|
1252
|
+
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
1253
|
+
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
1254
|
+
].join('\n');
|
|
1255
|
+
// The prompt is built from notes, observations and the action log; none
|
|
1256
|
+
// of them may carry the test account password into the report step.
|
|
1257
|
+
return { system, messages: [{ role: 'user', content: [{ type: 'text', text: scrubSecrets(text, secrets) }] }] };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1171
1260
|
/** The smallest output ceiling worth retrying a report turn with. */
|
|
1172
1261
|
export const REPORT_MIN_TOKENS = 1536;
|
|
1173
1262
|
|
|
@@ -1553,7 +1642,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
1553
1642
|
* screenshot list and says plainly that it is partial, rather than
|
|
1554
1643
|
* inventing a persona verdict nobody gave.
|
|
1555
1644
|
*/
|
|
1556
|
-
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
|
|
1645
|
+
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], actionRecords = [], play, reason, usage = null }) {
|
|
1557
1646
|
const good = notes.filter(note => note.kind === 'good');
|
|
1558
1647
|
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
1559
1648
|
const network = notes.filter(note => note.automatic && note.url);
|
|
@@ -1565,13 +1654,17 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1565
1654
|
// report. A partial report is still a report somebody reads, and a lint
|
|
1566
1655
|
// finding on it would be noise about a run that already went wrong.
|
|
1567
1656
|
return [
|
|
1568
|
-
`# Playtest: ${persona.name || persona.slug} (partial)`,
|
|
1657
|
+
`# Playtest: ${persona.name || persona.slug} (partial, written from the run log)`,
|
|
1569
1658
|
`**Driver:** ${driverName} - **Target:** ${target}`,
|
|
1570
1659
|
`**Session length:** ${play.actionsTaken} actions / ${play.quitReason}`,
|
|
1571
1660
|
'',
|
|
1572
1661
|
'## Why this report is partial',
|
|
1573
1662
|
reason,
|
|
1574
1663
|
'',
|
|
1664
|
+
inCharacterFromLog({ persona, notes, actionRecords, play }),
|
|
1665
|
+
'',
|
|
1666
|
+
dataPointsFromLog({ notes, screenshots, actions, play, usage }),
|
|
1667
|
+
'',
|
|
1575
1668
|
...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
|
|
1576
1669
|
'## What worked',
|
|
1577
1670
|
list(good),
|
|
@@ -1592,6 +1685,103 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1592
1685
|
].join('\n');
|
|
1593
1686
|
}
|
|
1594
1687
|
|
|
1688
|
+
/** The most lines of the persona's own words a log-written section quotes. */
|
|
1689
|
+
export const MAX_LOG_VOICE_LINES = 16;
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* The persona's mood, read off its own notes: what a report written from the
|
|
1693
|
+
* log says it felt, and the sentiment report.json carries. Only the notes the
|
|
1694
|
+
* persona wrote count: an automatic console note is not a feeling, and a note
|
|
1695
|
+
* written on a driver error is the runner's failure.
|
|
1696
|
+
*
|
|
1697
|
+
* @param {Array<Object>} notes
|
|
1698
|
+
* @returns {{counts: Object, sentiment: {fun: number, clarity: number, frustration: number}, wouldContinue: boolean, words: string, known: boolean}}
|
|
1699
|
+
*/
|
|
1700
|
+
export function moodFromNotes(notes = []) {
|
|
1701
|
+
const own = (notes || []).filter(note => note && !note.automatic && !note.driver_error && !note.expected_noise);
|
|
1702
|
+
const counts = { good: 0, bug: 0, confusing: 0, suggestion: 0, reaction: 0, waiting: 0 };
|
|
1703
|
+
for (const note of own) if (note.kind in counts) counts[note.kind] += 1;
|
|
1704
|
+
const clamp = value => Math.min(5, Math.max(1, Math.round(value)));
|
|
1705
|
+
const troubles = counts.bug + counts.confusing;
|
|
1706
|
+
const known = counts.good + troubles + counts.reaction + counts.suggestion > 0;
|
|
1707
|
+
if (!known) {
|
|
1708
|
+
return { counts, sentiment: { fun: 3, clarity: 3, frustration: 3 }, wouldContinue: false, words: 'unknown: the persona logged no notes about how it was going', known };
|
|
1709
|
+
}
|
|
1710
|
+
const sentiment = {
|
|
1711
|
+
fun: clamp(3 + counts.good - counts.bug - counts.confusing / 2),
|
|
1712
|
+
clarity: clamp(4 - counts.confusing - counts.bug / 2),
|
|
1713
|
+
frustration: clamp(1 + counts.bug + counts.confusing)
|
|
1714
|
+
};
|
|
1715
|
+
const words = counts.good > troubles
|
|
1716
|
+
? 'mostly positive'
|
|
1717
|
+
: troubles > counts.good
|
|
1718
|
+
? (counts.good > 0 ? 'mixed, leaning frustrated' : 'mostly frustrated or confused')
|
|
1719
|
+
: 'mixed';
|
|
1720
|
+
return { counts, sentiment, wouldContinue: counts.good > troubles, words, known };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* The in-character section of a report written from the log: the persona's
|
|
1725
|
+
* own words while it played (the thought before each action and the notes it
|
|
1726
|
+
* logged), in order, with its mood read off the notes and where it stopped.
|
|
1727
|
+
* Labelled as written from the log, because no report step wrote it.
|
|
1728
|
+
*/
|
|
1729
|
+
export function inCharacterFromLog({ persona, notes = [], actionRecords = [], play }) {
|
|
1730
|
+
const name = (persona && (persona.name || persona.slug)) || 'The persona';
|
|
1731
|
+
const lines = [];
|
|
1732
|
+
for (const record of actionRecords || []) {
|
|
1733
|
+
if (!record || record.kind !== 'action' || !record.thought) continue;
|
|
1734
|
+
const thought = String(record.thought).replace(/\s+/g, ' ').trim();
|
|
1735
|
+
if (!thought) continue;
|
|
1736
|
+
lines.push({ at: Number(record.actions) || 0, order: 0, text: `- Before action ${Number(record.actions) || 0} (${record.tool}${record.ok === false ? ', which did not go through' : ''}): "${clip(thought, 300)}"` });
|
|
1737
|
+
}
|
|
1738
|
+
for (const note of notes || []) {
|
|
1739
|
+
if (!note || note.automatic || !note.text) continue;
|
|
1740
|
+
// A note written on an action is recorded before that action is counted.
|
|
1741
|
+
const withAction = ACTION_TOOLS.includes(note.tool);
|
|
1742
|
+
const at = (Number(note.at_action) || 0) + (withAction ? 1 : 0);
|
|
1743
|
+
lines.push({ at, order: 1, text: `- ${withAction ? `Note with action ${at}` : `Note after ${at} action(s)`}, ${note.kind}${note.severity ? `/${note.severity}` : ''}: "${clip(String(note.text).replace(/\s+/g, ' ').trim(), 300)}"` });
|
|
1744
|
+
}
|
|
1745
|
+
lines.sort((a, b) => a.at - b.at || a.order - b.order);
|
|
1746
|
+
let shown = lines.map(line => line.text);
|
|
1747
|
+
if (shown.length > MAX_LOG_VOICE_LINES) {
|
|
1748
|
+
const half = MAX_LOG_VOICE_LINES / 2;
|
|
1749
|
+
shown = [...shown.slice(0, half), `- (${shown.length - MAX_LOG_VOICE_LINES} more in the transcript)`, ...shown.slice(-half)];
|
|
1750
|
+
}
|
|
1751
|
+
const mood = moodFromNotes(notes);
|
|
1752
|
+
const counted = Object.entries(mood.counts).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(', ');
|
|
1753
|
+
return [
|
|
1754
|
+
'## In-character experience (written from the run log)',
|
|
1755
|
+
`This section was put together by the runner from what ${name} wrote while playing, because the report step did not write one. The quoted words are the persona's own; nothing here was invented for it.`,
|
|
1756
|
+
'',
|
|
1757
|
+
shown.length > 0 ? shown.join('\n') : `- ${name} wrote no thoughts or notes before the run stopped.`,
|
|
1758
|
+
'',
|
|
1759
|
+
`**Mood, from its notes:** ${mood.words}${counted ? ` (${counted})` : ''}.`,
|
|
1760
|
+
`**Where it stopped:** after ${play.actionsTaken} action(s), ${play.quitReason}${play.quitDetail ? `: ${clip(String(play.quitDetail), 200).replace(/[.\s]+$/, '')}` : ''}.`
|
|
1761
|
+
].join('\n');
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** The run's numbers, for a report written from the log. */
|
|
1765
|
+
export function dataPointsFromLog({ notes = [], screenshots = [], actions = [], play, usage = null }) {
|
|
1766
|
+
const own = (notes || []).filter(note => note && !note.automatic);
|
|
1767
|
+
const automatic = (notes || []).filter(note => note && note.automatic);
|
|
1768
|
+
const changed = (actions || []).filter(entry => entry && entry.changed).length;
|
|
1769
|
+
const spend = usage && Number.isFinite(Number(usage.total_usd)) && Number(usage.total_usd) > 0 ? `$${Number(usage.total_usd).toFixed(4)}` : null;
|
|
1770
|
+
return [
|
|
1771
|
+
'## Data points',
|
|
1772
|
+
`- Actions taken: ${play.actionsTaken}${actions.length > 0 ? ` (${changed} of the ${actions.length} logged changed the screen)` : ''}`,
|
|
1773
|
+
`- Notes the persona wrote: ${own.length}`,
|
|
1774
|
+
`- Console and network notes the runner recorded: ${automatic.length}`,
|
|
1775
|
+
`- Screenshots saved: ${(screenshots || []).length}`,
|
|
1776
|
+
`- Stop reason: ${play.quitReason}`,
|
|
1777
|
+
...(spend ? [`- Model spend: ${spend}`] : [])
|
|
1778
|
+
].join('\n');
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function clip(text, max) {
|
|
1782
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1595
1785
|
/**
|
|
1596
1786
|
* The screenshot nearest a note, by the order they happened in; a tie goes
|
|
1597
1787
|
* to the later one, since a persona usually notes something and then
|
|
@@ -1661,10 +1851,14 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1661
1851
|
* to read them as one.
|
|
1662
1852
|
*/
|
|
1663
1853
|
async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords = [], play, skill, ledger, runId, jobId, startedAt, endedAt, quitReason = 'cost_cap', reason = BUDGET_PARTIAL_REASON, findings = null, preservedMarkdown = null }) {
|
|
1854
|
+
// The sentiment is read off the persona's own notes (moodFromNotes), and
|
|
1855
|
+
// is the scale's midpoint when it wrote none. `partial` still tells a reader
|
|
1856
|
+
// that no report step judged it.
|
|
1857
|
+
const mood = moodFromNotes(notes);
|
|
1664
1858
|
const report = stampReport({
|
|
1665
1859
|
findings: findings || notesToFindings(notes, screenshots),
|
|
1666
|
-
sentiment:
|
|
1667
|
-
would_continue:
|
|
1860
|
+
sentiment: mood.sentiment,
|
|
1861
|
+
would_continue: mood.wouldContinue,
|
|
1668
1862
|
partial: true
|
|
1669
1863
|
}, {
|
|
1670
1864
|
runId,
|
|
@@ -1687,8 +1881,10 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1687
1881
|
notes,
|
|
1688
1882
|
screenshots,
|
|
1689
1883
|
actions: actionLogFrom(actionRecords),
|
|
1884
|
+
actionRecords,
|
|
1690
1885
|
play,
|
|
1691
|
-
reason
|
|
1886
|
+
reason,
|
|
1887
|
+
usage: ledger.toReportUsage()
|
|
1692
1888
|
});
|
|
1693
1889
|
const markdown = preservedMarkdown
|
|
1694
1890
|
? ['# Playtest (partial report draft)', '',
|
|
@@ -1962,7 +2158,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1962
2158
|
const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1963
2159
|
if (observation) {
|
|
1964
2160
|
const entry = byIndex.get(Number(observation[1]));
|
|
1965
|
-
cited.push(entry && entry.driver_error ? 'driver' : 'other');
|
|
2161
|
+
cited.push(entry && entry.driver_error && !gameRefusal(entry.driver_error) ? 'driver' : 'other');
|
|
1966
2162
|
} else if (note) {
|
|
1967
2163
|
const entry = notes[Number(note[1]) - 1];
|
|
1968
2164
|
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.expected_noise ? 'noise' : entry && entry.driver_error ? 'driver' : 'other');
|
package/src/run/regenerate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { gameRefusal } from './gameRefusal.js';
|
|
3
4
|
import { RUN_FILES } from './paths.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -81,7 +82,9 @@ export async function readTranscript(runDirectory) {
|
|
|
81
82
|
notes.push(note);
|
|
82
83
|
if (entry.tool) openNotes.push(note);
|
|
83
84
|
} else if (entry.kind === 'driver_error') {
|
|
84
|
-
|
|
85
|
+
// A target the page showed as disabled was the game's answer, as the
|
|
86
|
+
// live run now records it; an older transcript is read the same way.
|
|
87
|
+
if (!gameRefusal(entry.error)) for (const note of callNotes) note.driver_error = true;
|
|
85
88
|
}
|
|
86
89
|
else if (entry.kind === 'screenshot' && entry.path) screenshots.push({ relativePath: entry.path, reason: entry.reason || '' });
|
|
87
90
|
else if (entry.kind === 'observation') observations += 1;
|
package/src/run/report.js
CHANGED
|
@@ -57,6 +57,60 @@ export function lintMarkdownSections(markdown) {
|
|
|
57
57
|
return REQUIRED_MARKDOWN_SECTIONS.filter(section => !text.includes(section));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** The tool-call tags a model has been seen to leak into a written file. */
|
|
61
|
+
const TOOL_TAG = '(?:parameter|invoke|content|function_calls|function_results|tool_use|tool_call|antml:[\\w-]+)';
|
|
62
|
+
|
|
63
|
+
/** A line made only of tags: closers of any name, or tool-call openers. */
|
|
64
|
+
const MARKUP_LINE = new RegExp(`^(?:\\s*(?:<\\/[A-Za-z][\\w:.-]*\\s*>|<${TOOL_TAG}\\b[^>]*>))+\\s*$`);
|
|
65
|
+
|
|
66
|
+
/** Tool-call closers glued to the end of the last line of real text. */
|
|
67
|
+
const MARKUP_SUFFIX = new RegExp(`(?:\\s*<\\/${TOOL_TAG}\\s*>)+\\s*$`);
|
|
68
|
+
|
|
69
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Takes tool-call markup off the end of a file the report step wrote. A real
|
|
73
|
+
* report.md ended "</content>\n</invoke>" because the model closed its tool
|
|
74
|
+
* call inside the file's content, and the runner appended its own sections
|
|
75
|
+
* after the stray tags.
|
|
76
|
+
*
|
|
77
|
+
* Only the end of the text is touched, and only outside a code fence: HTML a
|
|
78
|
+
* report quotes in backticks or in a fenced block is kept.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function stripTrailingToolMarkup(text) {
|
|
84
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
85
|
+
const lines = text.split('\n');
|
|
86
|
+
const insideFence = count => lines.slice(0, count).filter(line => FENCE.test(line)).length % 2 === 1;
|
|
87
|
+
let end = lines.length;
|
|
88
|
+
let changed = false;
|
|
89
|
+
while (end > 0) {
|
|
90
|
+
const line = lines[end - 1];
|
|
91
|
+
if (line.trim() === '') {
|
|
92
|
+
end -= 1;
|
|
93
|
+
} else if (MARKUP_LINE.test(line) && !insideFence(end - 1)) {
|
|
94
|
+
end -= 1;
|
|
95
|
+
changed = true;
|
|
96
|
+
} else {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const kept = lines.slice(0, end);
|
|
101
|
+
if (kept.length > 0) {
|
|
102
|
+
const index = kept.length - 1;
|
|
103
|
+
const last = kept[index];
|
|
104
|
+
const backticks = (last.match(/`/g) || []).length;
|
|
105
|
+
if (MARKUP_SUFFIX.test(last) && backticks % 2 === 0 && !FENCE.test(last) && !insideFence(index)) {
|
|
106
|
+
kept[index] = last.replace(MARKUP_SUFFIX, '');
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!changed) return text;
|
|
111
|
+
return `${kept.join('\n').replace(/\s+$/, '')}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
export class ReportInvalidError extends Error {
|
|
61
115
|
/**
|
|
62
116
|
* @param {Array<{instancePath: string, message: string}>} details
|