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.
- package/README.md +79 -8
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +55 -2
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +376 -41
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +247 -37
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
package/src/run/personaLoop.js
CHANGED
|
@@ -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
|
-
/**
|
|
81
|
-
|
|
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
|
-
|
|
454
|
-
if (
|
|
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
|
-
|
|
461
|
-
|
|
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.
|
|
469
|
-
|
|
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'
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
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();
|
package/src/run/regenerate.js
CHANGED
|
@@ -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)
|
|
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'
|
|
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
|
-
/**
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|