ravensight-playtest 0.2.9 → 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,3 +1,4 @@
1
+ import { rewriteMarkdown } from './dropped.js';
1
2
  import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
@@ -69,6 +70,8 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
69
70
  export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
70
71
 
71
72
  /** A persona note kind as a report finding category. */
73
+ // `waiting` has no category on purpose: a "waiting for X to finish" note is
74
+ // context, and nothing turns it into a finding.
72
75
  export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
73
76
 
74
77
  /** Stop injecting the "nothing changed" hint after this many identical screens. */
@@ -77,8 +80,16 @@ export const STUCK_HINT_AFTER = 5;
77
80
  /** Give up entirely after this many identical screens: the persona is wedged. */
78
81
  export const STUCK_LIMIT = 12;
79
82
 
80
- /** Turns allowed after a budget warning, to land the report. */
81
- 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;
82
93
 
83
94
  /** Token ceiling asked for per act turn. The proxy clamps it to the step's own. */
84
95
  export const ACT_MAX_TOKENS = 1024;
@@ -330,6 +341,7 @@ async function playLoop({
330
341
  keepObservations,
331
342
  heartbeat,
332
343
  onProgress = () => {},
344
+ onBudgetWarning = null,
333
345
  log = () => {}
334
346
  }) {
335
347
  const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
@@ -361,7 +373,6 @@ async function playLoop({
361
373
  let budgetExceeded = false;
362
374
  let canceled = false;
363
375
  let unchanged = 0;
364
- let wrapUpLeft = Infinity;
365
376
  const maxTurns = maxActions * 3 + 20;
366
377
 
367
378
  // Notes waiting for the next observation, which is the screen they are
@@ -405,13 +416,9 @@ async function playLoop({
405
416
  quitDetail = 'the job was cancelled';
406
417
  break;
407
418
  }
408
- if (wrapUpLeft <= 0) {
409
- quitReason = 'budget_exhausted';
410
- break;
411
- }
412
-
413
419
  turns += 1;
414
420
  let answer;
421
+ const askedAt = Date.now();
415
422
  try {
416
423
  answer = await callWithImageRetry({
417
424
  client,
@@ -444,30 +451,28 @@ async function playLoop({
444
451
  throw error;
445
452
  }
446
453
 
447
- ledger.record({ model: answer.model, usage: answer.usage });
454
+ ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
448
455
  // The warn turn is a turn the developer paid for, so its tool calls are
449
456
  // executed like any other and the wrap-up notice rides along on that
450
457
  // turn's tool_result. Two things this avoids: discarding a paid action,
451
458
  // and pushing a second consecutive `user` message, which the Messages API
452
459
  // refuses when the previous turn ended in a tool_use.
453
- let wrapUpNotice = null;
454
- if (answer.budget === 'warn' && !budgetWarned) {
460
+ const warnedNow = answer.budget === 'warn' && !budgetWarned;
461
+ if (warnedNow) {
455
462
  budgetWarned = true;
456
- wrapUpLeft = WRAP_UP_TURNS;
457
463
  // Surfaced, not swallowed: this is the developer's money and the one
458
464
  // moment where telling them costs nothing and saying nothing costs a
459
- // report.
460
- 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.`);
461
- 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.');
462
468
  transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
469
+ if (typeof onBudgetWarning === 'function') await onBudgetWarning();
463
470
  }
464
- if (budgetWarned) wrapUpLeft -= 1;
465
471
 
466
472
  if (answer.toolUses.length === 0) {
467
473
  // No tool call is how a persona stops: there is no quit tool in the
468
- // step's allowlist, and adding one would be refused by the proxy. A warn
469
- // on such a turn needs no notice either, since the loop ends here.
470
- 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';
471
476
  quitDetail = answer.text.slice(0, 400);
472
477
  transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
473
478
  break;
@@ -484,15 +489,25 @@ async function playLoop({
484
489
  }
485
490
  transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
486
491
  if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
487
- 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
+ }
488
498
  }
489
499
  messages.push({ role: 'assistant', content: assistantContent(answer) });
490
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;
491
504
  for (const toolUse of answer.toolUses) {
492
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;
493
507
  const executed = await executeTool({
494
508
  driver,
495
509
  toolUse,
510
+ staleRefs: screenChangedThisTurn,
496
511
  onNote: note => {
497
512
  // The note is nested rather than spread: its own `kind`
498
513
  // (bug, confusing, good, suggestion, reaction) would otherwise
@@ -508,6 +523,7 @@ async function playLoop({
508
523
  });
509
524
  results.push(executed.result);
510
525
  if (executed.spentAction) actions += 1;
526
+ if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
511
527
  transcript.write({
512
528
  kind: 'action',
513
529
  tool: toolUse.name,
@@ -518,8 +534,18 @@ async function playLoop({
518
534
  changed: executed.changed,
519
535
  actions
520
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
+ }
521
546
  if (executed.observation) {
522
- observed(executed.observation);
547
+ const entry = observed(executed.observation);
548
+ if (executed.driverError) entry.driver_error = String(executed.error || 'driver error').slice(0, 200);
523
549
  latestObservation = executed.observation.text;
524
550
  visualObservation = Boolean(executed.observation.screenshotBase64);
525
551
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
@@ -561,8 +587,6 @@ async function playLoop({
561
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}.` : ''}`);
562
588
  }
563
589
 
564
- if (wrapUpNotice) asides.push(wrapUpNotice);
565
-
566
590
  const wedged = unchanged >= STUCK_LIMIT;
567
591
  if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
568
592
  transcript.write({ kind: 'stuck', unchanged, fatal: false });
@@ -584,6 +608,12 @@ async function playLoop({
584
608
  }
585
609
 
586
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
+ }
587
617
  }
588
618
 
589
619
  if (actions >= maxActions && quitReason === 'budget_exhausted') {
@@ -645,6 +675,8 @@ async function reportPhase({
645
675
  notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
646
676
  '',
647
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.',
648
680
  '',
649
681
  'Observations the persona was shown, by index:',
650
682
  observationLines(observations),
@@ -674,6 +706,7 @@ async function reportPhase({
674
706
  let jsonText = null;
675
707
  let repaired = false;
676
708
  let continued = false;
709
+ let droppedByRunner = [];
677
710
 
678
711
  // One repair at most when the schema or the evidence refs refuse what was
679
712
  // written: each turn is a metered question, and the dogfood gate asks for
@@ -682,19 +715,56 @@ async function reportPhase({
682
715
  // gets one plain "now write report.json" that does not spend the repair.
683
716
  // In 0.2.8 it did, and the report.json that came back had its five
684
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;
685
727
  for (let attempt = 1; ; attempt += 1) {
686
- const answer = await callStepWithRetry({
687
- client,
688
- jobId,
689
- runId,
690
- step: STEPS.personaReport,
691
- system,
692
- messages,
693
- tools: REPORT_TOOLS,
694
- maxTokens: REPORT_MAX_TOKENS,
695
- escalate: shouldEscalate(persona)
696
- });
697
- ledger.record({ model: answer.model, usage: answer.usage });
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 });
698
768
  transcript.write({ kind: 'report_turn', attempt, stop_reason: answer.stopReason, files: answer.toolUses.map(t => t.input && t.input.path) });
699
769
 
700
770
  const files = new Map();
@@ -737,7 +807,14 @@ async function reportPhase({
737
807
  if (normalised && normalised.changed > 0) {
738
808
  transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
739
809
  }
740
- const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
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;
741
818
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
742
819
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
743
820
  // The server lints the same four sections at `complete` and records the
@@ -798,6 +875,10 @@ async function reportPhase({
798
875
  if (!markdown) {
799
876
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
800
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
+ }
801
882
  const generatedAt = new Date().toISOString();
802
883
  const facts = reportFacts({ persona, driverName, target, play });
803
884
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
@@ -808,6 +889,72 @@ async function reportPhase({
808
889
  return { report, markdown, lint };
809
890
  }
810
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
+
811
958
  /**
812
959
  * `callStepWithRetry`, plus the one retry that is about our own request
813
960
  * rather than the server's mood: `too_many_images` means this step allows
@@ -980,6 +1127,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
980
1127
  }
981
1128
  }
982
1129
  outcome.usage = ledger.toReportUsage();
1130
+ outcome.duration = ledger.duration();
983
1131
  await writeUsageFile(runDirectory, ledger);
984
1132
  transcript.write({ kind: 'end', state: outcome.state, quit_reason: outcome.quitReason, actions: outcome.actionsTaken });
985
1133
  await transcript.close();
@@ -1068,6 +1216,8 @@ export function notesToFindings(notes, screenshots) {
1068
1216
  for (const note of notes) {
1069
1217
  const category = NOTE_CATEGORY[note.kind];
1070
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;
1071
1221
  const text = String(note.text);
1072
1222
  const shot = nearestScreenshot(note, screenshots);
1073
1223
  findings.push({
@@ -1145,6 +1295,7 @@ async function writePartialMarkdown({ runDirectory, persona, driverName, target,
1145
1295
  export function noteLine(note, i) {
1146
1296
  const tags = [
1147
1297
  note.automatic ? 'automatic' : null,
1298
+ note.driver_error ? 'written on a driver error' : null,
1148
1299
  Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1149
1300
  note.screenshot ? `screenshot ${note.screenshot}` : null
1150
1301
  ].filter(Boolean);
@@ -1182,7 +1333,7 @@ export const MAX_OBSERVATIONS_IN_REPORT = 200;
1182
1333
  function observationLines(observations) {
1183
1334
  if (!observations || observations.length === 0) return '(none)';
1184
1335
  const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
1185
- const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
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}]` : ''}`);
1186
1337
  if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
1187
1338
  return lines.join('\n');
1188
1339
  }
@@ -1215,6 +1366,65 @@ export function observationTitle(observation) {
1215
1366
  * @param {{notes: Array<Object>, screenshots: Array<Object>}} context
1216
1367
  * @returns {Object} the report, with capped findings rewritten
1217
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
+
1218
1428
  export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1219
1429
  if (!report || !Array.isArray(report.findings)) return report;
1220
1430
  const byShot = new Map();
@@ -47,7 +47,7 @@ export async function readTranscript(runDirectory) {
47
47
  try {
48
48
  text = await readFile(path.join(runDirectory, RUN_FILES.transcript), 'utf8');
49
49
  } catch {
50
- return { header: null, notes: [], screenshots: [], observations: 0, actions: 0 };
50
+ return { header: null, notes: [], screenshots: [], observations: 0, actions: 0, firstT: null, lastT: null };
51
51
  }
52
52
 
53
53
  let header = null;
@@ -55,6 +55,12 @@ export async function readTranscript(runDirectory) {
55
55
  const screenshots = [];
56
56
  let observations = 0;
57
57
  let actions = 0;
58
+ // Notes written on the tool call the last `action` line closed, so a
59
+ // `driver_error` after it can mark them as the live run did.
60
+ let callNotes = [];
61
+ let openNotes = [];
62
+ let firstT = null;
63
+ let lastT = null;
58
64
  for (const line of text.split('\n')) {
59
65
  if (!line.trim()) continue;
60
66
  let entry;
@@ -65,13 +71,27 @@ export async function readTranscript(runDirectory) {
65
71
  // it rather than refusing to read the whole transcript.
66
72
  continue;
67
73
  }
74
+ if (Number.isFinite(Number(entry.t))) {
75
+ if (firstT === null) firstT = Number(entry.t);
76
+ lastT = Number(entry.t);
77
+ }
68
78
  if (entry.kind === 'header') header = entry;
69
- else if (entry.kind === 'note' && entry.note) notes.push(entry.note);
79
+ else if (entry.kind === 'note' && entry.note) {
80
+ const note = { ...entry.note };
81
+ notes.push(note);
82
+ if (entry.tool) openNotes.push(note);
83
+ } else if (entry.kind === 'driver_error') {
84
+ for (const note of callNotes) note.driver_error = true;
85
+ }
70
86
  else if (entry.kind === 'screenshot' && entry.path) screenshots.push({ relativePath: entry.path, reason: entry.reason || '' });
71
87
  else if (entry.kind === 'observation') observations += 1;
72
- else if (entry.kind === 'action' && Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
88
+ else if (entry.kind === 'action') {
89
+ callNotes = openNotes;
90
+ openNotes = [];
91
+ if (Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
92
+ }
73
93
  }
74
- return { header, notes, screenshots, observations, actions };
94
+ return { header, notes, screenshots, observations, actions, firstT, lastT };
75
95
  }
76
96
 
77
97
  /**
package/src/run/resume.js CHANGED
@@ -244,7 +244,13 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
244
244
  actions_taken: report.actions_taken,
245
245
  quit_reason: 'error',
246
246
  report,
247
- report_md: markdown
247
+ report_md: markdown,
248
+ // The transcript's own span: its first and last timestamps. No model
249
+ // time is known for a run rebuilt after the process died.
250
+ duration: {
251
+ wall_seconds: transcript.firstT !== null && transcript.lastT !== null ? Number(((transcript.lastT - transcript.firstT) / 1000).toFixed(3)) : null,
252
+ model_seconds: null
253
+ }
248
254
  }
249
255
  });
250
256
  if (answer && answer.dropped_findings > 0) {
package/src/run/tools.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
2
- import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
2
+ import { OBSERVATION_MAX_CHARS, STALE_REF_MESSAGE, truncateObservation } from './drivers/driver.js';
3
+
4
+ export { STALE_REF_MESSAGE };
3
5
 
4
6
  /**
5
7
  * The client tool set for `persona_playtest.act`.
@@ -23,8 +25,12 @@ import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js'
23
25
  * `snapshot`, `screenshot` and `wait` do not.
24
26
  */
25
27
 
26
- /** Note kinds, from spec 03's `log_note`. */
27
- export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction']);
28
+ /**
29
+ * Note kinds, from spec 03's `log_note`, plus `waiting`: the persona is
30
+ * waiting for something to finish (a load, an animation, a turn). A waiting
31
+ * note is context for the report and never becomes a finding.
32
+ */
33
+ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction', 'waiting']);
28
34
 
29
35
  /** Severities, from the report schema's finding severity enum. */
30
36
  export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
@@ -52,7 +58,7 @@ const note = {
52
58
  additionalProperties: false,
53
59
  required: ['kind', 'text'],
54
60
  properties: {
55
- kind: { type: 'string', enum: [...NOTE_KINDS] },
61
+ kind: { type: 'string', enum: [...NOTE_KINDS], description: 'waiting means you are waiting for something to finish; it is recorded as context and never reported as a finding.' },
56
62
  text: { type: 'string' },
57
63
  severity: { type: 'string', enum: [...SEVERITIES] }
58
64
  }
@@ -71,12 +77,13 @@ export const PLAY_TOOLS = Object.freeze([
71
77
  },
72
78
  {
73
79
  name: 'click',
74
- description: 'Click one thing. Spends an action. Either name the target or give x and y pixel coordinates. What a named target means depends on the driver: on playwright_web it is an accessibility node such as button "Start", a visible text fragment, or a CSS selector; on godot_driver it is a node path from the scene tree you were shown.',
80
+ description: 'Click one thing. Spends an action. On playwright_web, use the ref the screen shows next to the element, such as e12: it names exactly one element, even when two share a name. Otherwise name the target: on playwright_web an accessibility node such as button "Start" or a visible text fragment; on godot_driver a node path from the scene tree you were shown. Targets are refs or visible text, never locator code such as getByRole(...) or page.locator(...). Avoid x and y coordinates on playwright_web: they are blind to the page, and are only for a canvas with no refs, taken from the @(x,y) the screen shows or from the screenshot.',
75
81
  input_schema: {
76
82
  type: 'object',
77
83
  additionalProperties: false,
78
84
  properties: {
79
- target: { type: 'string', description: 'web: button "Start", Start Game, or #start-button. Godot: a node path such as /root/Main/UI/StartButton.' },
85
+ ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
86
+ target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
80
87
  x: { type: 'number' },
81
88
  y: { type: 'number' },
82
89
  thought_in_character: thought,
@@ -95,7 +102,8 @@ export const PLAY_TOOLS = Object.freeze([
95
102
  text: { type: 'string' },
96
103
  submit: { type: 'boolean' },
97
104
  replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
98
- target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it, the focused field.' },
105
+ ref: { type: 'string', description: 'playwright_web only, the preferred way to name the field: its ref, such as e12.' },
106
+ target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it or a ref, the focused field.' },
99
107
  thought_in_character: thought,
100
108
  note
101
109
  }
@@ -186,6 +194,7 @@ export function actionFor(toolUse) {
186
194
  const input = toolUse.input || {};
187
195
  switch (toolUse.name) {
188
196
  case 'click':
197
+ if (typeof input.ref === 'string' && input.ref.trim()) return { kind: 'click', ref: input.ref.trim() };
189
198
  return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
190
199
  ? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
191
200
  : { kind: 'click', target: String(input.target || '') };
@@ -193,6 +202,7 @@ export function actionFor(toolUse) {
193
202
  const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
194
203
  if (input.replace === true) action.replace = true;
195
204
  if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
205
+ if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
196
206
  return action;
197
207
  }
198
208
  case 'key':
@@ -220,9 +230,11 @@ export function actionFor(toolUse) {
220
230
  * @param {Object} args.toolUse
221
231
  * @param {(note: Object) => void} [args.onNote]
222
232
  * @param {(shot: Object) => void} [args.onScreenshot]
233
+ * @param {boolean} [args.staleRefs] an earlier action in this turn changed the
234
+ * screen, so a ref in this call was read off a screen that is gone
223
235
  * @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
224
236
  */
225
- export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
237
+ export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false }) {
226
238
  const input = toolUse.input || {};
227
239
  if (toolUse.malformed) {
228
240
  return {
@@ -280,7 +292,15 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
280
292
  };
281
293
  }
282
294
 
283
- const outcome = await driver.act(action);
295
+ // A ref is a playwright_web target. Every other driver is told so by name,
296
+ // before anything reaches the game.
297
+ // A ref in a later tool call of a turn whose earlier call already changed
298
+ // the screen was read off a screen that is gone.
299
+ const outcome = action.ref && !driver.supportsRefs
300
+ ? { ok: false, changed: false, error: `ref targets are only available on playwright_web; ${driver.name || 'this driver'} takes a named target or coordinates`, driverError: true }
301
+ : action.ref && staleRefs
302
+ ? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
303
+ : await driver.act(action);
284
304
  const observation = await driver.observe();
285
305
  const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
286
306
  const prefix = outcome.ok
@@ -291,7 +311,8 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
291
311
  observation,
292
312
  spentAction,
293
313
  changed: Boolean(outcome.changed),
294
- error: outcome.ok ? null : String(outcome.error || 'act failed')
314
+ error: outcome.ok ? null : String(outcome.error || 'act failed'),
315
+ driverError: !outcome.ok && Boolean(outcome.driverError)
295
316
  };
296
317
  }
297
318