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.
@@ -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
+ }
@@ -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 { 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
 
@@ -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
- quitDetail = answer.text.slice(0, 400);
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 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);
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
- files.set(name, String(toolUse.input.content || ''));
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: { fun: 3, clarity: 3, frustration: 3 },
1667
- would_continue: false,
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');
@@ -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;
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