ravensight-playtest 0.2.8 → 0.3.0

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