ravensight-playtest 0.7.0 → 0.7.5

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.
@@ -2,16 +2,18 @@ import { rewriteMarkdown } from './dropped.js';
2
2
  import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
5
+ import { prepareMessages, repairLastTurn, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
6
6
  import { withRollingCacheBreakpoint } from './fixLoop.js';
7
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
8
  import { RUN_FILES } from './paths.js';
9
- import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
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
 
@@ -74,6 +76,25 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
74
76
  /** What a capped finding's description gains, so the reader knows why. */
75
77
  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.';
76
78
 
79
+ /** How each way of leaving the build reads in a note. */
80
+ const OFF_SITE_VIA = Object.freeze({
81
+ navigation: 'a link, form or script on the page',
82
+ 'new window': 'a new window',
83
+ redirect: 'a redirect',
84
+ navigate: 'the persona\'s navigate'
85
+ });
86
+
87
+ /**
88
+ * The network note for one attempt to leave the build.
89
+ *
90
+ * @param {{host?: string, via?: string}} attempt
91
+ * @returns {string}
92
+ */
93
+ export function offSiteNoteText(attempt) {
94
+ const via = OFF_SITE_VIA[attempt && attempt.via] || 'the page';
95
+ return `off-site: ${via} tried to leave the build for ${(attempt && attempt.host) || 'another site'}; the runner kept the page on the build.`;
96
+ }
97
+
77
98
  /** A persona note kind as a report finding category. */
78
99
  // `waiting` has no category on purpose: a "waiting for X to finish" note is
79
100
  // context, and nothing turns it into a finding.
@@ -102,6 +123,31 @@ export const ACT_MAX_TOKENS = 1024;
102
123
  /** Token ceiling asked for when writing the report. */
103
124
  export const REPORT_MAX_TOKENS = 4096;
104
125
 
126
+ /** The most characters of a persona's closing message kept as its quit detail. */
127
+ export const MAX_QUIT_DETAIL_CHARS = 160;
128
+
129
+ /**
130
+ * A persona's closing message as a one-line quit detail: its first sentence,
131
+ * without markdown, cut to MAX_QUIT_DETAIL_CHARS. A message that is already
132
+ * report text says only that the persona stopped.
133
+ *
134
+ * @param {string} text
135
+ * @returns {string}
136
+ */
137
+ export function personaQuitDetail(text) {
138
+ const plain = 'the persona stopped playing';
139
+ const first = String(text || '').split(/\n/).map(line => line.replace(/[*_`#>]+/g, '').trim()).find(Boolean) || '';
140
+ if (!first || /report\.(md|json)|write_file|\b(?:writ(?:e|ing)|start(?:ing)? on) (?:the |my )?(?:final )?report\b/i.test(first)) return plain;
141
+ const sentence = (first.match(/^.*?[.!?](?=\s|$)/) || [first])[0];
142
+ if (sentence.length <= MAX_QUIT_DETAIL_CHARS) return sentence;
143
+ const cut = sentence.slice(0, MAX_QUIT_DETAIL_CHARS - 3);
144
+ const space = cut.lastIndexOf(' ');
145
+ return `${space > 40 ? cut.slice(0, space) : cut}...`;
146
+ }
147
+
148
+ /** The stop detail when play ends to keep the report's budget. */
149
+ export const RESERVE_STOP_DETAIL = 'stopped early so the rest of the model budget pays for the report';
150
+
105
151
  /**
106
152
  * @typedef {Object} PersonaRunOutcome
107
153
  * @property {string} state one of succeeded, failed, canceled, budget_exceeded
@@ -373,6 +419,11 @@ async function playLoop({
373
419
  heartbeat,
374
420
  onProgress = () => {},
375
421
  onBudgetWarning = null,
422
+ // The run's model budget in USD, from register (`limits.model_usd`). With it
423
+ // the loop stops early enough that the report still fits; without it (an
424
+ // older server) only the server's 80 percent warning does.
425
+ modelBudgetUsd = null,
426
+ reportSkill = null,
376
427
  log = () => {}
377
428
  }) {
378
429
  const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
@@ -406,9 +457,39 @@ async function playLoop({
406
457
  let budgetWarned = false;
407
458
  let budgetExceeded = false;
408
459
  let reportBudgetAvailable = false;
460
+ let reserveStopped = false;
409
461
  let canceled = false;
410
462
  let unchanged = 0;
411
463
  const maxTurns = maxActions * 3 + 20;
464
+ // What the last few act turns really cost, in weighted units.
465
+ const recentActUnits = [];
466
+
467
+ /**
468
+ * Whether the next act turn would leave too little for the report. Sized
469
+ * from the act request about to be sent and the report request as it would
470
+ * be built now; see ./budgetReserve.js.
471
+ */
472
+ const reserveCheck = () => {
473
+ const spend = modelBudgetUsd ? ledger.spendEstimate() : null;
474
+ if (!spend) return { stop: false, known: false, figures: {} };
475
+ const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: imageBudget, keepObservations }));
476
+ const actChars = JSON.stringify({ messages: prepared, max_tokens: ACT_MAX_TOKENS, stream: true, system, tools: playTools }).length;
477
+ const reportRequest = buildReportRequest({
478
+ skill: reportSkill || skill, persona, brief, localBrief, intentNotes, driverName, target, runId, jobId,
479
+ play: { actionsTaken: actions, quitReason: 'budget_exhausted', quitDetail: RESERVE_STOP_DETAIL, actionLog: actionLogFrom(actionRecords) },
480
+ notes, observations, screenshots
481
+ });
482
+ const reportChars = JSON.stringify({ messages: reportRequest.messages, max_tokens: REPORT_MAX_TOKENS, stream: true, system: reportRequest.system, tools: REPORT_TOOLS }).length;
483
+ return reserveDecision({
484
+ usdMax: modelBudgetUsd,
485
+ spend,
486
+ actChars,
487
+ actMaxTokens: ACT_MAX_TOKENS,
488
+ actCostUnits: recentActUnits.length > 0 ? Math.max(...recentActUnits) : 0,
489
+ reportChars,
490
+ reportMaxTokens: REPORT_MAX_TOKENS
491
+ });
492
+ };
412
493
 
413
494
  // Notes waiting for the next observation, which is the screen they are
414
495
  // checked against: a failure the next screen does not show is not a
@@ -454,26 +535,53 @@ async function playLoop({
454
535
  quitDetail = 'the job was cancelled';
455
536
  break;
456
537
  }
538
+ if (turns > 0) {
539
+ const reserve = reserveCheck();
540
+ if (reserve.stop) {
541
+ reserveStopped = true;
542
+ quitReason = 'budget_exhausted';
543
+ quitDetail = RESERVE_STOP_DETAIL;
544
+ transcript.write({ kind: 'budget_reserve_stop', ...reserve.figures });
545
+ 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}).`);
546
+ break;
547
+ }
548
+ }
457
549
  turns += 1;
458
550
  let answer;
459
551
  const askedAt = Date.now();
552
+ const askAct = () => callWithImageRetry({
553
+ client,
554
+ jobId,
555
+ runId,
556
+ step: STEPS.personaAct,
557
+ system,
558
+ messages,
559
+ tools: playTools,
560
+ maxTokens: ACT_MAX_TOKENS,
561
+ escalate: shouldEscalate(persona),
562
+ imageBudget,
563
+ keepObservations,
564
+ onImageBudget: next => {
565
+ imageBudget = next;
566
+ }
567
+ });
460
568
  try {
461
- answer = await callWithImageRetry({
462
- client,
463
- jobId,
464
- runId,
465
- step: STEPS.personaAct,
466
- system,
467
- messages,
468
- tools: playTools,
469
- maxTokens: ACT_MAX_TOKENS,
470
- escalate: shouldEscalate(persona),
471
- imageBudget,
472
- keepObservations,
473
- onImageBudget: next => {
474
- imageBudget = next;
475
- }
476
- });
569
+ try {
570
+ answer = await askAct();
571
+ } catch (error) {
572
+ // The provider refused the body. Every tool_result is already made
573
+ // valid before it is sent (./context.js), so this is a shape nobody
574
+ // has seen yet: the newest turn is rewritten as plain text and asked
575
+ // once more. A second refusal ends play, and the run still reports
576
+ // from its step log (runPersona's error path), because a run that
577
+ // played never comes back empty.
578
+ if (!(error instanceof ProxyError) || error.code !== 'upstream_rejected') throw error;
579
+ log(`the model provider rejected this turn (${error.message}); retrying once with the last turn as plain text.`);
580
+ transcript.write({ kind: 'provider_rejected', retry: true, code: error.code, provider_type: error.figures.provider_type || null, provider_message: error.figures.provider_message || null });
581
+ const repaired = repairLastTurn(messages);
582
+ messages.splice(0, messages.length, ...repaired);
583
+ answer = await askAct();
584
+ }
477
585
  } catch (error) {
478
586
  if (error instanceof ProxyError && error.isBudget) {
479
587
  budgetExceeded = true;
@@ -491,6 +599,8 @@ async function playLoop({
491
599
  }
492
600
 
493
601
  ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
602
+ recentActUnits.push(ledger.lastCallUnits || 0);
603
+ if (recentActUnits.length > 3) recentActUnits.shift();
494
604
  // The warn turn is a turn the developer paid for, so its tool calls are
495
605
  // executed like any other and the wrap-up notice rides along on that
496
606
  // turn's tool_result. Two things this avoids: discarding a paid action,
@@ -513,7 +623,12 @@ async function playLoop({
513
623
  // No tool call is how a persona stops: there is no quit tool in the
514
624
  // step's allowlist, and adding one would be refused by the proxy.
515
625
  quitReason = warnedNow ? 'budget_exhausted' : 'persona_quit';
516
- quitDetail = answer.text.slice(0, 400);
626
+ // The whole closing message is in the transcript. The detail is one
627
+ // short line: it becomes report.json's quit_detail, and a closing turn
628
+ // is often the persona starting on its report ("Now writing the final
629
+ // report. **report.md:** ..."), which in 0.7.1 ended up as a
630
+ // succeeded run's reason on the dashboard.
631
+ quitDetail = personaQuitDetail(answer.text);
517
632
  transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
518
633
  break;
519
634
  }
@@ -583,6 +698,7 @@ async function playLoop({
583
698
  ok: !executed.error,
584
699
  error: executed.error,
585
700
  changed: executed.changed,
701
+ ...(executed.refused ? { refused: executed.refused } : {}),
586
702
  actions
587
703
  };
588
704
  transcript.write(actionLine);
@@ -611,6 +727,13 @@ async function playLoop({
611
727
  // declared wedged for doing the right thing.
612
728
  if (ACTION_TOOLS.includes(toolUse.name)) unchanged = executed.changed ? 0 : unchanged + 1;
613
729
  }
730
+ if (executed.refused) {
731
+ // A refused off-site navigation is a step that changed nothing: it
732
+ // counts toward the stuck limit like any other, and play goes on.
733
+ unchanged += 1;
734
+ recentActions.push(`${toolUse.name}: refused, off-site`);
735
+ if (recentActions.length > 10) recentActions.shift();
736
+ }
614
737
  onProgress({ phase: 'playing', persona: persona.slug, actions, maxActions, tool: toolUse.name });
615
738
  }
616
739
 
@@ -652,6 +775,24 @@ async function playLoop({
652
775
  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}.` : ''}`);
653
776
  }
654
777
 
778
+ // Every attempt to leave the build this turn (a clicked link, a form, a
779
+ // new window, a script or a server redirect, or a refused navigate) is a
780
+ // network note: the runner kept the page on the build, and the report
781
+ // step reads it as context. A persona told "Refused" already knows; a
782
+ // timer redirect between actions is only ever seen here.
783
+ const leaving = typeof driver.offSiteAttempts === 'function' ? driver.offSiteAttempts() : [];
784
+ for (const attempt of leaving) {
785
+ const note = {
786
+ kind: 'network',
787
+ text: offSiteNoteText(attempt),
788
+ automatic: true,
789
+ off_site: true,
790
+ url: String(attempt.url || attempt.host || '').slice(0, 200)
791
+ };
792
+ noted(note);
793
+ transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
794
+ }
795
+
655
796
  const wedged = unchanged >= STUCK_LIMIT;
656
797
  if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
657
798
  transcript.write({ kind: 'stuck', unchanged, fatal: false });
@@ -695,6 +836,7 @@ async function playLoop({
695
836
  budgetWarned,
696
837
  budgetExceeded,
697
838
  reportBudgetAvailable,
839
+ reserveStopped,
698
840
  canceled,
699
841
  imageBudget,
700
842
  finalScreenshot: final ? final.relativePath : null,
@@ -733,55 +875,7 @@ async function reportPhase({
733
875
  // The report step never sees the test account: it has nothing to sign in
734
876
  // to, and the password crosses the model proxy during play only.
735
877
  const secrets = secretsOf(localBrief);
736
- const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
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);
878
+ const { system, messages } = buildReportRequest({ skill, persona, brief, localBrief, intentNotes, driverName, target, runId, jobId, play, notes, observations, screenshots });
785
879
 
786
880
  let report = null;
787
881
  let markdown = null;
@@ -808,7 +902,7 @@ async function reportPhase({
808
902
  // never escalates to a dearer tier after a warning, and a 402 on a report
809
903
  // turn gets one retry with the output ceiling cut to what the refusal says
810
904
  // still fits.
811
- const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
905
+ const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded && !play.reserveStopped;
812
906
  let maxTokens = REPORT_MAX_TOKENS;
813
907
  let shrunk = false;
814
908
  let lastCandidate = null;
@@ -868,7 +962,9 @@ async function reportPhase({
868
962
  if (toolUse.name !== 'write_file' || toolUse.malformed) continue;
869
963
  const name = String(toolUse.input.path || '');
870
964
  if (name !== RUN_FILES.reportMd && name !== RUN_FILES.reportJson) continue;
871
- files.set(name, String(toolUse.input.content || ''));
965
+ // A model sometimes closes its tool call inside the file's content;
966
+ // the stray tags come off before the file is validated or written.
967
+ files.set(name, stripTrailingToolMarkup(String(toolUse.input.content || '')));
872
968
  }
873
969
  if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
874
970
  if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
@@ -1168,6 +1264,60 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
1168
1264
  return { report: { ...report, findings }, downgraded };
1169
1265
  }
1170
1266
 
1267
+ /**
1268
+ * The report step's request: its system prompt and its one user message. Built
1269
+ * the same way for the call and for sizing it during play (./budgetReserve.js),
1270
+ * so the budget kept back for the report is the report's own size.
1271
+ *
1272
+ * @returns {{system: Array, messages: Array}}
1273
+ */
1274
+ export function buildReportRequest({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, runId, jobId, play, notes, observations = [], screenshots }) {
1275
+ const secrets = secretsOf(localBrief);
1276
+ const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
1277
+ const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
1278
+ const text = [
1279
+ `The session is over. Write report.md and report.json for run ${runId} of job ${jobId}.`,
1280
+ `persona: ${persona.slug} driver: ${driverName} target: ${target}`,
1281
+ `actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
1282
+ '',
1283
+ 'Notes you logged, in order:',
1284
+ notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
1285
+ '',
1286
+ '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.',
1287
+ '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.',
1288
+ `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.`,
1289
+ '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.',
1290
+ '',
1291
+ 'Observations the persona was shown, by index:',
1292
+ observationLines(observations),
1293
+ '',
1294
+ 'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
1295
+ actionLogLines(play.actionLog),
1296
+ '',
1297
+ '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.',
1298
+ '',
1299
+ '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):',
1300
+ networkNoteLines(notes),
1301
+ '',
1302
+ '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"}.',
1303
+ '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.',
1304
+ '',
1305
+ '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".',
1306
+ '',
1307
+ '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.',
1308
+ 'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
1309
+ '',
1310
+ 'Screenshots available as evidence (use these exact paths in evidence refs):',
1311
+ screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
1312
+ '',
1313
+ `report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
1314
+ 'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
1315
+ ].join('\n');
1316
+ // The prompt is built from notes, observations and the action log; none
1317
+ // of them may carry the test account password into the report step.
1318
+ return { system, messages: [{ role: 'user', content: [{ type: 'text', text: scrubSecrets(text, secrets) }] }] };
1319
+ }
1320
+
1171
1321
  /** The smallest output ceiling worth retrying a report turn with. */
1172
1322
  export const REPORT_MIN_TOKENS = 1536;
1173
1323
 
@@ -1553,7 +1703,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
1553
1703
  * screenshot list and says plainly that it is partial, rather than
1554
1704
  * inventing a persona verdict nobody gave.
1555
1705
  */
1556
- export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
1706
+ export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], actionRecords = [], play, reason, usage = null }) {
1557
1707
  const good = notes.filter(note => note.kind === 'good');
1558
1708
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1559
1709
  const network = notes.filter(note => note.automatic && note.url);
@@ -1565,13 +1715,17 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1565
1715
  // report. A partial report is still a report somebody reads, and a lint
1566
1716
  // finding on it would be noise about a run that already went wrong.
1567
1717
  return [
1568
- `# Playtest: ${persona.name || persona.slug} (partial)`,
1718
+ `# Playtest: ${persona.name || persona.slug} (partial, written from the run log)`,
1569
1719
  `**Driver:** ${driverName} - **Target:** ${target}`,
1570
1720
  `**Session length:** ${play.actionsTaken} actions / ${play.quitReason}`,
1571
1721
  '',
1572
1722
  '## Why this report is partial',
1573
1723
  reason,
1574
1724
  '',
1725
+ inCharacterFromLog({ persona, notes, actionRecords, play }),
1726
+ '',
1727
+ dataPointsFromLog({ notes, screenshots, actions, play, usage }),
1728
+ '',
1575
1729
  ...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
1576
1730
  '## What worked',
1577
1731
  list(good),
@@ -1592,6 +1746,103 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1592
1746
  ].join('\n');
1593
1747
  }
1594
1748
 
1749
+ /** The most lines of the persona's own words a log-written section quotes. */
1750
+ export const MAX_LOG_VOICE_LINES = 16;
1751
+
1752
+ /**
1753
+ * The persona's mood, read off its own notes: what a report written from the
1754
+ * log says it felt, and the sentiment report.json carries. Only the notes the
1755
+ * persona wrote count: an automatic console note is not a feeling, and a note
1756
+ * written on a driver error is the runner's failure.
1757
+ *
1758
+ * @param {Array<Object>} notes
1759
+ * @returns {{counts: Object, sentiment: {fun: number, clarity: number, frustration: number}, wouldContinue: boolean, words: string, known: boolean}}
1760
+ */
1761
+ export function moodFromNotes(notes = []) {
1762
+ const own = (notes || []).filter(note => note && !note.automatic && !note.driver_error && !note.expected_noise);
1763
+ const counts = { good: 0, bug: 0, confusing: 0, suggestion: 0, reaction: 0, waiting: 0 };
1764
+ for (const note of own) if (note.kind in counts) counts[note.kind] += 1;
1765
+ const clamp = value => Math.min(5, Math.max(1, Math.round(value)));
1766
+ const troubles = counts.bug + counts.confusing;
1767
+ const known = counts.good + troubles + counts.reaction + counts.suggestion > 0;
1768
+ if (!known) {
1769
+ return { counts, sentiment: { fun: 3, clarity: 3, frustration: 3 }, wouldContinue: false, words: 'unknown: the persona logged no notes about how it was going', known };
1770
+ }
1771
+ const sentiment = {
1772
+ fun: clamp(3 + counts.good - counts.bug - counts.confusing / 2),
1773
+ clarity: clamp(4 - counts.confusing - counts.bug / 2),
1774
+ frustration: clamp(1 + counts.bug + counts.confusing)
1775
+ };
1776
+ const words = counts.good > troubles
1777
+ ? 'mostly positive'
1778
+ : troubles > counts.good
1779
+ ? (counts.good > 0 ? 'mixed, leaning frustrated' : 'mostly frustrated or confused')
1780
+ : 'mixed';
1781
+ return { counts, sentiment, wouldContinue: counts.good > troubles, words, known };
1782
+ }
1783
+
1784
+ /**
1785
+ * The in-character section of a report written from the log: the persona's
1786
+ * own words while it played (the thought before each action and the notes it
1787
+ * logged), in order, with its mood read off the notes and where it stopped.
1788
+ * Labelled as written from the log, because no report step wrote it.
1789
+ */
1790
+ export function inCharacterFromLog({ persona, notes = [], actionRecords = [], play }) {
1791
+ const name = (persona && (persona.name || persona.slug)) || 'The persona';
1792
+ const lines = [];
1793
+ for (const record of actionRecords || []) {
1794
+ if (!record || record.kind !== 'action' || !record.thought) continue;
1795
+ const thought = String(record.thought).replace(/\s+/g, ' ').trim();
1796
+ if (!thought) continue;
1797
+ 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)}"` });
1798
+ }
1799
+ for (const note of notes || []) {
1800
+ if (!note || note.automatic || !note.text) continue;
1801
+ // A note written on an action is recorded before that action is counted.
1802
+ const withAction = ACTION_TOOLS.includes(note.tool);
1803
+ const at = (Number(note.at_action) || 0) + (withAction ? 1 : 0);
1804
+ 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)}"` });
1805
+ }
1806
+ lines.sort((a, b) => a.at - b.at || a.order - b.order);
1807
+ let shown = lines.map(line => line.text);
1808
+ if (shown.length > MAX_LOG_VOICE_LINES) {
1809
+ const half = MAX_LOG_VOICE_LINES / 2;
1810
+ shown = [...shown.slice(0, half), `- (${shown.length - MAX_LOG_VOICE_LINES} more in the transcript)`, ...shown.slice(-half)];
1811
+ }
1812
+ const mood = moodFromNotes(notes);
1813
+ const counted = Object.entries(mood.counts).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(', ');
1814
+ return [
1815
+ '## In-character experience (written from the run log)',
1816
+ `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.`,
1817
+ '',
1818
+ shown.length > 0 ? shown.join('\n') : `- ${name} wrote no thoughts or notes before the run stopped.`,
1819
+ '',
1820
+ `**Mood, from its notes:** ${mood.words}${counted ? ` (${counted})` : ''}.`,
1821
+ `**Where it stopped:** after ${play.actionsTaken} action(s), ${play.quitReason}${play.quitDetail ? `: ${clip(String(play.quitDetail), 200).replace(/[.\s]+$/, '')}` : ''}.`
1822
+ ].join('\n');
1823
+ }
1824
+
1825
+ /** The run's numbers, for a report written from the log. */
1826
+ export function dataPointsFromLog({ notes = [], screenshots = [], actions = [], play, usage = null }) {
1827
+ const own = (notes || []).filter(note => note && !note.automatic);
1828
+ const automatic = (notes || []).filter(note => note && note.automatic);
1829
+ const changed = (actions || []).filter(entry => entry && entry.changed).length;
1830
+ const spend = usage && Number.isFinite(Number(usage.total_usd)) && Number(usage.total_usd) > 0 ? `$${Number(usage.total_usd).toFixed(4)}` : null;
1831
+ return [
1832
+ '## Data points',
1833
+ `- Actions taken: ${play.actionsTaken}${actions.length > 0 ? ` (${changed} of the ${actions.length} logged changed the screen)` : ''}`,
1834
+ `- Notes the persona wrote: ${own.length}`,
1835
+ `- Console and network notes the runner recorded: ${automatic.length}`,
1836
+ `- Screenshots saved: ${(screenshots || []).length}`,
1837
+ `- Stop reason: ${play.quitReason}`,
1838
+ ...(spend ? [`- Model spend: ${spend}`] : [])
1839
+ ].join('\n');
1840
+ }
1841
+
1842
+ function clip(text, max) {
1843
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
1844
+ }
1845
+
1595
1846
  /**
1596
1847
  * The screenshot nearest a note, by the order they happened in; a tie goes
1597
1848
  * to the later one, since a persona usually notes something and then
@@ -1661,10 +1912,14 @@ export function notesToFindings(notes, screenshots) {
1661
1912
  * to read them as one.
1662
1913
  */
1663
1914
  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 }) {
1915
+ // The sentiment is read off the persona's own notes (moodFromNotes), and
1916
+ // is the scale's midpoint when it wrote none. `partial` still tells a reader
1917
+ // that no report step judged it.
1918
+ const mood = moodFromNotes(notes);
1664
1919
  const report = stampReport({
1665
1920
  findings: findings || notesToFindings(notes, screenshots),
1666
- sentiment: { fun: 3, clarity: 3, frustration: 3 },
1667
- would_continue: false,
1921
+ sentiment: mood.sentiment,
1922
+ would_continue: mood.wouldContinue,
1668
1923
  partial: true
1669
1924
  }, {
1670
1925
  runId,
@@ -1687,8 +1942,10 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1687
1942
  notes,
1688
1943
  screenshots,
1689
1944
  actions: actionLogFrom(actionRecords),
1945
+ actionRecords,
1690
1946
  play,
1691
- reason
1947
+ reason,
1948
+ usage: ledger.toReportUsage()
1692
1949
  });
1693
1950
  const markdown = preservedMarkdown
1694
1951
  ? ['# Playtest (partial report draft)', '',
@@ -1962,7 +2219,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1962
2219
  const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1963
2220
  if (observation) {
1964
2221
  const entry = byIndex.get(Number(observation[1]));
1965
- cited.push(entry && entry.driver_error ? 'driver' : 'other');
2222
+ cited.push(entry && entry.driver_error && !gameRefusal(entry.driver_error) ? 'driver' : 'other');
1966
2223
  } else if (note) {
1967
2224
  const entry = notes[Number(note[1]) - 1];
1968
2225
  cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.expected_noise ? 'noise' : entry && entry.driver_error ? 'driver' : 'other');
@@ -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
- for (const note of callNotes) note.driver_error = true;
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;