ravensight-playtest 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -6
- package/package.json +1 -1
- package/src/cli.js +9 -4
- package/src/commands/check.js +81 -0
- package/src/commands/fix.js +8 -4
- package/src/commands/init.js +29 -4
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +69 -23
- package/src/run/aggregate.js +10 -5
- package/src/run/args.js +17 -3
- package/src/run/context.js +12 -1
- package/src/run/drivers/web.js +149 -9
- package/src/run/personaLoop.js +159 -14
- package/src/run/tools.js +7 -2
- package/src/ui/index.js +28 -0
package/README.md
CHANGED
|
@@ -87,6 +87,18 @@ content pack verified by sha256, disk, recommended concurrency, and the two
|
|
|
87
87
|
optional tools. Exit code 3 when any row fails; a missing optional tool is a
|
|
88
88
|
warning and never a failure, so CI does not break on it.
|
|
89
89
|
|
|
90
|
+
For a web game there is also a `browser` row: `check` imports Playwright,
|
|
91
|
+
launches a headless Chromium and closes it, which is exactly how a web run
|
|
92
|
+
starts. A missing browser fails the check when the linked build is a URL or you
|
|
93
|
+
pass `--web`. When no build is linked yet and the configured driver is the
|
|
94
|
+
default `playwright_web`, it is only a warning, so a Godot repo that never
|
|
95
|
+
changed the default is not failed over a browser it will not use. A repo linked
|
|
96
|
+
to a Godot project or a driver config gets no browser row. The fix is:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm i -D playwright && npx playwright install chromium
|
|
100
|
+
```
|
|
101
|
+
|
|
90
102
|
Past `--personas` in `POST /jobs`, requesting more than 100 personas or the same
|
|
91
103
|
slug twice is refused with 400 `too_many_personas`, and a slug this game does
|
|
92
104
|
not have is 400 `unknown_persona`; `run` checks the count against the same
|
|
@@ -101,6 +113,7 @@ automatically. `check` shows that same warning as a row when it applies.
|
|
|
101
113
|
| `--modules <list>` | Price this module list, comma separated. |
|
|
102
114
|
| `--personas <list>` | Price this persona list. Without it the balance row prices no personas, so the quote is lower than a real job's. |
|
|
103
115
|
| `--godot-path <path>` | Where Godot is, if it is not on the PATH. |
|
|
116
|
+
| `--web` | Check the browser even when this repo is not linked to a web build. |
|
|
104
117
|
|
|
105
118
|
### init
|
|
106
119
|
|
|
@@ -108,9 +121,15 @@ Links this repo to a game and writes `.ravensight/config.json`. Refuses a game
|
|
|
108
121
|
your token cannot see, so the first job does not fail with a 404. Adds
|
|
109
122
|
`.ravensight/jobs/` to an existing `.gitignore`.
|
|
110
123
|
|
|
124
|
+
Without `--game`, one visible game is linked as is. With several, `init` lists
|
|
125
|
+
them numbered and asks `Which game? [1-N]` when it is running in a terminal; in
|
|
126
|
+
CI or a pipe it prints the list and asks you to pass `--game` instead. A token
|
|
127
|
+
that can see no games is told so, with the fix (approve a game for the token at
|
|
128
|
+
login).
|
|
129
|
+
|
|
111
130
|
| Flag | Effect |
|
|
112
131
|
|---|---|
|
|
113
|
-
| `--game <gameId>` | The game to link.
|
|
132
|
+
| `--game <gameId>` | The game to link. Optional with one visible game, or in a terminal. |
|
|
114
133
|
| `--force` | Relink a repo that is already linked. |
|
|
115
134
|
|
|
116
135
|
### brief
|
|
@@ -130,6 +149,30 @@ warning `run` shows. A brief is required before `run`; the server answers 409
|
|
|
130
149
|
| `--game <gameId>` | The game, when this repo is not linked. |
|
|
131
150
|
| `--modules <list>`, `--personas <list>` | Price this module and persona list in the estimate it prints. |
|
|
132
151
|
|
|
152
|
+
#### The `brief --from` file
|
|
153
|
+
|
|
154
|
+
A JSON object of brief fields, or the same object wrapped as
|
|
155
|
+
`{ "fields": { ... } }`, which is how a saved brief carries them. The first four
|
|
156
|
+
are what makes a brief complete; the rest are optional.
|
|
157
|
+
|
|
158
|
+
| Field | Shape | Notes |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `goals` | `[{ "goal": "...", "priority": 1 }]` | At least one. `goal` is one of `find_bugs`, `onboarding`, `balance`, `level_flow`, `narrative`, `accessibility`, `performance`. `priority` is 1 (highest) to 5. |
|
|
161
|
+
| `success_criteria` | string | What success looks like, one criterion per line. The aggregate report scores each line. |
|
|
162
|
+
| `build_notes.controls` | string | How to play: keys, buttons, the verbs a persona needs. |
|
|
163
|
+
| `build_notes.how_to_start` | string | Where a persona begins: the URL, the menu path, the first thing to press. |
|
|
164
|
+
| `design_intent_notes` | array of strings | Deliberate choices a persona must not report as bugs. |
|
|
165
|
+
| `known_issues` | array of strings | Issues you already know about, so they are not reported again. |
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"goals": [{ "goal": "onboarding", "priority": 1 }, { "goal": "find_bugs", "priority": 2 }],
|
|
170
|
+
"success_criteria": "A new player reaches level 2\nNobody gets stuck in the menu",
|
|
171
|
+
"build_notes": { "controls": "Arrow keys to move, space to jump", "how_to_start": "Press Start on the title screen" },
|
|
172
|
+
"design_intent_notes": ["The fog on level 1 is intentional"]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
133
176
|
### profile
|
|
134
177
|
|
|
135
178
|
Builds the game profile and the capability report: what your game is, what can
|
|
@@ -158,7 +201,7 @@ splitting the job in two is what gets everything played.
|
|
|
158
201
|
| `--build-url <url>` | The web build to play. |
|
|
159
202
|
| `--godot-project <dir>` | The Godot project directory. |
|
|
160
203
|
| `--cli <command>` | The command that starts a text build. |
|
|
161
|
-
| `--max-actions <n>` |
|
|
204
|
+
| `--max-actions <n>` | Lower the actions one persona may take. It can only lower the persona's own patience (and the server's per run ceiling), never raise it. |
|
|
162
205
|
| `--concurrency <n>` | Personas at once. Default 2; `check` recommends a number for this machine. |
|
|
163
206
|
| `--upload-video` | Upload `session.webm` as well as the reports. |
|
|
164
207
|
| `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
|
|
@@ -166,10 +209,24 @@ splitting the job in two is what gets everything played.
|
|
|
166
209
|
| `--headed`, `--headless` | Show or hide the browser. |
|
|
167
210
|
| `--verbose` | Print each turn. |
|
|
168
211
|
|
|
169
|
-
|
|
170
|
-
silently ignored: `--
|
|
171
|
-
|
|
172
|
-
|
|
212
|
+
Three flags are accepted and reported as not applied by this release, rather
|
|
213
|
+
than silently ignored: `--quality` (the model tier is the server's, enforced at
|
|
214
|
+
the proxy), `--offline-upload` (uploads already queue) and `--build-dir` (serve
|
|
215
|
+
the build and pass `--build-url`). There is no spending flag: each run's model
|
|
216
|
+
budget is set by the server from what the job was charged, and `--budget-usd` is
|
|
217
|
+
an unknown option (the refusal says where the budget comes from).
|
|
218
|
+
|
|
219
|
+
Each run hands its playtest identity to the game, so the game's own Ravensight
|
|
220
|
+
events from a playtest are marked synthetic rather than counted as a player. A
|
|
221
|
+
Godot game reads it from its environment. A web game's JavaScript SDK reads it
|
|
222
|
+
from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
|
|
223
|
+
`localStorage['ravensight.playtest']` before the game's scripts run. It is never
|
|
224
|
+
sent as a request header, which would reach your game's own API too.
|
|
225
|
+
|
|
226
|
+
When a run reaches its model budget before the report step, nothing more is
|
|
227
|
+
asked of the model. The persona's notes are written to a partial `report.json`
|
|
228
|
+
(`partial: true`, one unreviewed finding per note with the nearest screenshot as
|
|
229
|
+
evidence) and uploaded, so the job's aggregate still counts them.
|
|
173
230
|
|
|
174
231
|
### fix
|
|
175
232
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -50,7 +50,7 @@ async function loadRunner(name, exportName) {
|
|
|
50
50
|
* Kept in the same order as `VALUED` and `BOOLEAN` in src/run/args.js, which is
|
|
51
51
|
* the list it has to agree with.
|
|
52
52
|
*/
|
|
53
|
-
const RUNNER_HELP = `
|
|
53
|
+
export const RUNNER_HELP = `
|
|
54
54
|
Run options (parsed by the runner, see src/run/args.js):
|
|
55
55
|
--personas <list> comma separated persona slugs, default the pack's set
|
|
56
56
|
--driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
|
|
@@ -58,7 +58,7 @@ Run options (parsed by the runner, see src/run/args.js):
|
|
|
58
58
|
--build-url <url> the web build to play
|
|
59
59
|
--godot-project <dir> the Godot project directory
|
|
60
60
|
--driver-config <file> local WebDriver or JSON-RPC bridge configuration
|
|
61
|
-
--max-actions <n>
|
|
61
|
+
--max-actions <n> lower the actions one persona may take; it can only lower the persona's own patience, never raise it
|
|
62
62
|
--concurrency <n> personas at once, default 2
|
|
63
63
|
--upload-video upload session.webm as well as the reports
|
|
64
64
|
--upload-transcript upload transcript.jsonl as well as the reports
|
|
@@ -68,7 +68,10 @@ Run options (parsed by the runner, see src/run/args.js):
|
|
|
68
68
|
--verbose print each turn
|
|
69
69
|
|
|
70
70
|
Accepted and reported as not applied by this release:
|
|
71
|
-
--
|
|
71
|
+
--quality, --offline-upload, --build-dir
|
|
72
|
+
|
|
73
|
+
There is no spending flag: each run's model budget is set by the server from
|
|
74
|
+
what the job was charged.
|
|
72
75
|
`;
|
|
73
76
|
|
|
74
77
|
/** Flags every command shares. */
|
|
@@ -157,13 +160,15 @@ export function buildProgram() {
|
|
|
157
160
|
.addOption(new Option('--modules <list>', 'price this module list').argParser(list))
|
|
158
161
|
.addOption(new Option('--personas <list>', 'price this persona list').argParser(list))
|
|
159
162
|
.option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
|
|
163
|
+
.option('--web', 'check the browser even when this repo is not linked to a web build')
|
|
160
164
|
.action(capture(slot, async options => {
|
|
161
165
|
const { check } = await import('./commands/check.js');
|
|
162
166
|
return check(flagsFrom(options, {
|
|
163
167
|
game: options.game,
|
|
164
168
|
modules: options.modules,
|
|
165
169
|
personas: options.personas,
|
|
166
|
-
godotPath: options.godotPath
|
|
170
|
+
godotPath: options.godotPath,
|
|
171
|
+
web: Boolean(options.web)
|
|
167
172
|
}));
|
|
168
173
|
}));
|
|
169
174
|
|
package/src/commands/check.js
CHANGED
|
@@ -19,6 +19,75 @@ const row = (name, status, detail, fix = null) => ({ name, status, detail, fix }
|
|
|
19
19
|
/** "1 persona", "3 personas". A doctor that cannot count reads like a draft. */
|
|
20
20
|
const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
21
21
|
|
|
22
|
+
/** The two commands that make the playwright_web driver work. */
|
|
23
|
+
export const BROWSER_FIX = 'npm i -D playwright && npx playwright install chromium';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The repo config, for deciding whether this is a web game. A config that
|
|
27
|
+
* cannot be read is reported by the rows that need it, not here.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} flags
|
|
30
|
+
* @returns {Promise<Object|null>}
|
|
31
|
+
*/
|
|
32
|
+
async function configForBrowser(flags) {
|
|
33
|
+
if (flags.config !== undefined) return flags.config;
|
|
34
|
+
try {
|
|
35
|
+
return await loadConfig({ repoRoot: flags.repoRoot });
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How much a missing browser matters here, or null when the row is skipped.
|
|
43
|
+
*
|
|
44
|
+
* `fail` when `--web` asks for it or the linked build is a URL: a web run
|
|
45
|
+
* cannot start without one. `warn` when only the configured driver says
|
|
46
|
+
* `playwright_web` and no build is linked yet, because that is the untouched
|
|
47
|
+
* default `init` writes, and a Godot developer who never changed it must not
|
|
48
|
+
* get exit 3 over a browser they will never launch. Skipped otherwise.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} flags
|
|
51
|
+
* @param {Object|null} config
|
|
52
|
+
* @returns {'fail'|'warn'|null}
|
|
53
|
+
*/
|
|
54
|
+
export function browserSeverity(flags, config) {
|
|
55
|
+
if (flags.web) return 'fail';
|
|
56
|
+
if (!config) return null;
|
|
57
|
+
const kind = config.build && config.build.kind;
|
|
58
|
+
if (kind === 'url') return 'fail';
|
|
59
|
+
if (kind) return null;
|
|
60
|
+
return config.driver === 'playwright_web' ? 'warn' : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Import Playwright, launch headless Chromium, close it. The same three steps
|
|
65
|
+
* a web run starts with, so a pass here means the run will get a browser.
|
|
66
|
+
*
|
|
67
|
+
* @param {(specifier: string) => Promise<any>} [importer] test seam
|
|
68
|
+
* @returns {Promise<{ok: boolean, detail: string}>}
|
|
69
|
+
*/
|
|
70
|
+
export async function probeBrowser(importer) {
|
|
71
|
+
const load = importer || (specifier => import(specifier));
|
|
72
|
+
let browser = null;
|
|
73
|
+
try {
|
|
74
|
+
const playwright = await load('playwright');
|
|
75
|
+
browser = await playwright.chromium.launch({ headless: true });
|
|
76
|
+
const version = typeof browser.version === 'function' ? browser.version() : '';
|
|
77
|
+
return { ok: true, detail: `chromium ${version}`.trim() + ' launched headless' };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
|
|
80
|
+
} finally {
|
|
81
|
+
if (browser) {
|
|
82
|
+
try {
|
|
83
|
+
await browser.close();
|
|
84
|
+
} catch {
|
|
85
|
+
// A browser that will not close still launched, which is what was asked.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
22
91
|
/**
|
|
23
92
|
* Every environment check, as data.
|
|
24
93
|
*
|
|
@@ -37,6 +106,7 @@ const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
|
37
106
|
* @param {string[]} [flags.modules]
|
|
38
107
|
* @param {string[]} [flags.personas]
|
|
39
108
|
* @param {string} [flags.repoRoot]
|
|
109
|
+
* @param {boolean} [flags.web] probe the browser whatever the config says
|
|
40
110
|
* @param {Object} [deps]
|
|
41
111
|
* @returns {Promise<{rows: Array, ok: boolean}>}
|
|
42
112
|
*/
|
|
@@ -243,6 +313,17 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
243
313
|
'needed only to re-encode a session video before upload'
|
|
244
314
|
));
|
|
245
315
|
|
|
316
|
+
const browserNeed = browserSeverity(flags, await configForBrowser(flags));
|
|
317
|
+
if (browserNeed) {
|
|
318
|
+
const probe = await probeBrowser(deps.importer);
|
|
319
|
+
rows.push(row(
|
|
320
|
+
'browser',
|
|
321
|
+
probe.ok ? 'ok' : browserNeed,
|
|
322
|
+
probe.detail,
|
|
323
|
+
probe.ok ? null : BROWSER_FIX
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
|
|
246
327
|
const godot = await (deps.inspectGodot || inspectGodot)(
|
|
247
328
|
{ repoRoot, godotPath: flags.godotPath },
|
|
248
329
|
deps
|
package/src/commands/fix.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { optionsFrom } from '../run/args.js';
|
|
4
|
+
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
5
5
|
import { getDeps } from '../run/deps.js';
|
|
6
6
|
import { createDriver } from '../run/drivers/index.js';
|
|
7
7
|
import EXIT from '../run/exit.js';
|
|
@@ -15,7 +15,7 @@ import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
|
|
|
15
15
|
import { assertNoSecrets } from '../run/secretScan.js';
|
|
16
16
|
import * as stateFile from '../run/state.js';
|
|
17
17
|
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
18
|
-
import { createInterruptHandler, deliver, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
|
|
18
|
+
import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* `ravensight-playtest fix <target>`: take one finding, change the code on a
|
|
@@ -155,6 +155,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
155
155
|
const { options, unknown } = optionsFrom(flags);
|
|
156
156
|
if (unknown.length > 0) {
|
|
157
157
|
log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest fix --help".`);
|
|
158
|
+
for (const line of unknownOptionNotes(unknown)) log(line);
|
|
158
159
|
return EXIT.ENVIRONMENT;
|
|
159
160
|
}
|
|
160
161
|
const parsed = parseTarget(target);
|
|
@@ -506,6 +507,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
506
507
|
repoRoot,
|
|
507
508
|
entry: verifyRun,
|
|
508
509
|
pack,
|
|
510
|
+
brief,
|
|
509
511
|
finding,
|
|
510
512
|
driverName,
|
|
511
513
|
target: buildTarget,
|
|
@@ -752,6 +754,7 @@ export async function runVerify({
|
|
|
752
754
|
repoRoot,
|
|
753
755
|
entry,
|
|
754
756
|
pack,
|
|
757
|
+
brief = null,
|
|
755
758
|
finding,
|
|
756
759
|
driverName,
|
|
757
760
|
target,
|
|
@@ -769,7 +772,7 @@ export async function runVerify({
|
|
|
769
772
|
return null;
|
|
770
773
|
}
|
|
771
774
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
772
|
-
const driver = await makeDriver(driverName, { headless });
|
|
775
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
773
776
|
const ledger = createUsageLedger();
|
|
774
777
|
const heartbeat = createHeartbeat({
|
|
775
778
|
api,
|
|
@@ -796,7 +799,8 @@ export async function runVerify({
|
|
|
796
799
|
target,
|
|
797
800
|
runDirectory: directory,
|
|
798
801
|
skill: skillFor(pack, 'persona_playtest'),
|
|
799
|
-
brief:
|
|
802
|
+
// The brief fetched above, as `run` does: the pack does not always carry one.
|
|
803
|
+
brief: verifyBrief(brief || pack.brief || null, finding),
|
|
800
804
|
intentNotes: pack.intent_notes || [],
|
|
801
805
|
schemas: pack.schemas || [],
|
|
802
806
|
maxActions,
|
package/src/commands/init.js
CHANGED
|
@@ -37,7 +37,8 @@ export async function ensureGitignore(repoRoot) {
|
|
|
37
37
|
/**
|
|
38
38
|
* `ravensight-playtest init --game <gameId>`
|
|
39
39
|
*
|
|
40
|
-
* Link this repo to a Ravensight game.
|
|
40
|
+
* Link this repo to a Ravensight game. Without `--game`, one visible game is
|
|
41
|
+
* used as is, and several are offered as a numbered list on a terminal.
|
|
41
42
|
*
|
|
42
43
|
* The game is checked against `whoami` before anything is written, because the
|
|
43
44
|
* alternative is a config that looks right and fails on the first job with a
|
|
@@ -45,7 +46,7 @@ export async function ensureGitignore(repoRoot) {
|
|
|
45
46
|
* authority: an unapproved game is invisible to it, by design.
|
|
46
47
|
*
|
|
47
48
|
* @param {{game?: string, apiUrl?: string, repoRoot?: string, force?: boolean, json?: boolean}} flags
|
|
48
|
-
* @param {Object} [deps]
|
|
49
|
+
* @param {{context?: Object, choose?: Function}} [deps]
|
|
49
50
|
* @returns {Promise<number>}
|
|
50
51
|
*/
|
|
51
52
|
export async function init(flags = {}, deps = {}) {
|
|
@@ -58,15 +59,39 @@ export async function init(flags = {}, deps = {}) {
|
|
|
58
59
|
// Every API call in this command goes through the one 401 handler, so a
|
|
59
60
|
// refused credential is cleared and explained here the same way it is
|
|
60
61
|
// everywhere else.
|
|
61
|
-
return withAuthHandling(context, () => linkRepo(context, flags, repoRoot));
|
|
62
|
+
return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose));
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* @param {Object} context
|
|
67
|
+
* @param {Object} flags
|
|
68
|
+
* @param {string} repoRoot
|
|
69
|
+
* @param {(question: string, labels: Array<string>) => Promise<number|null>} choose
|
|
70
|
+
* the picker; answers null when there is no terminal to ask on
|
|
71
|
+
*/
|
|
72
|
+
async function linkRepo(context, flags, repoRoot, choose) {
|
|
65
73
|
const whoami = await context.api.cli.whoami();
|
|
66
74
|
|
|
67
75
|
const games = whoami.games || [];
|
|
68
76
|
let gameId = flags.game;
|
|
69
77
|
|
|
78
|
+
if (!gameId && games.length === 0) {
|
|
79
|
+
throw new CliError(
|
|
80
|
+
'This token can see no games, so there is nothing to link.',
|
|
81
|
+
1,
|
|
82
|
+
{ hint: 'Create a game in the dashboard, then run login again and approve it for this token.' }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Never under --json: whatever reads that output is a script, not a person.
|
|
87
|
+
if (!gameId && games.length > 1 && !flags.json) {
|
|
88
|
+
// On a terminal, ask. Anywhere else (CI, a pipe) the picker answers null
|
|
89
|
+
// and the command falls through to the list and the --game error, which
|
|
90
|
+
// is what a script needs to see.
|
|
91
|
+
const picked = await choose(`Which game? [1-${games.length}]`, games.map(game => `${game.name || game.gameId} (${game.gameId})`));
|
|
92
|
+
if (Number.isInteger(picked) && games[picked]) gameId = games[picked].gameId;
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
if (!gameId) {
|
|
71
96
|
if (games.length === 1) {
|
|
72
97
|
gameId = games[0].gameId;
|
package/src/commands/profile.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
|
-
import { optionsFrom } from '../run/args.js';
|
|
3
|
+
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
4
4
|
import { getDeps } from '../run/deps.js';
|
|
5
5
|
import EXIT from '../run/exit.js';
|
|
6
6
|
import { createHeartbeat } from '../run/heartbeat.js';
|
|
@@ -55,6 +55,7 @@ async function profile(flags, injected, deps, log) {
|
|
|
55
55
|
const { options, unknown } = optionsFrom(flags);
|
|
56
56
|
if (unknown.length > 0) {
|
|
57
57
|
log(`I do not know these options: ${unknown.join(', ')}.`);
|
|
58
|
+
for (const line of unknownOptionNotes(unknown)) log(line);
|
|
58
59
|
return EXIT.ENVIRONMENT;
|
|
59
60
|
}
|
|
60
61
|
|
package/src/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { buildAggregate, writeAggregate } from '../run/aggregate.js';
|
|
5
|
-
import { optionsFrom } from '../run/args.js';
|
|
5
|
+
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
6
6
|
import { getDeps } from '../run/deps.js';
|
|
7
7
|
import { createDriver } from '../run/drivers/index.js';
|
|
8
8
|
import EXIT from '../run/exit.js';
|
|
@@ -80,6 +80,7 @@ async function run(flags, injected, deps, log) {
|
|
|
80
80
|
const { options, unknown } = optionsFrom(flags);
|
|
81
81
|
if (unknown.length > 0) {
|
|
82
82
|
log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest run --help".`);
|
|
83
|
+
for (const line of unknownOptionNotes(unknown)) log(line);
|
|
83
84
|
return EXIT.ENVIRONMENT;
|
|
84
85
|
}
|
|
85
86
|
for (const line of unusedFlagWarnings(options)) log(line);
|
|
@@ -294,17 +295,17 @@ export function personaCountRefusal(count, maxPersonas) {
|
|
|
294
295
|
/**
|
|
295
296
|
* Flags this release parses and then does not act on.
|
|
296
297
|
*
|
|
297
|
-
* Saying so is the point: a developer who passes
|
|
298
|
-
*
|
|
298
|
+
* Saying so is the point: a developer who passes a flag and is not told it
|
|
299
|
+
* did nothing will believe it did something. `--budget-usd` is not here
|
|
300
|
+
* because it is not parsed at all any more: the model budget is the server's,
|
|
301
|
+
* derived from what the job was charged and enforced per call at the proxy,
|
|
302
|
+
* so a spending flag the CLI cannot honour is refused rather than accepted.
|
|
299
303
|
*
|
|
300
304
|
* @param {Object} options
|
|
301
305
|
* @returns {Array<string>}
|
|
302
306
|
*/
|
|
303
307
|
export function unusedFlagWarnings(options) {
|
|
304
308
|
const warnings = [];
|
|
305
|
-
if (options.budgetUsd !== undefined) {
|
|
306
|
-
warnings.push('--budget-usd is not applied by this release: the model budget is the server\'s, derived from what the job was charged, and it is enforced per call at the proxy.');
|
|
307
|
-
}
|
|
308
309
|
if (options.quality !== undefined) {
|
|
309
310
|
warnings.push('--quality is not applied by this release: the model tier is the server\'s decision, and the CLI is not allowed to name one.');
|
|
310
311
|
}
|
|
@@ -392,6 +393,7 @@ export async function playJob({
|
|
|
392
393
|
journal,
|
|
393
394
|
inFlight,
|
|
394
395
|
makeDriver,
|
|
396
|
+
brief,
|
|
395
397
|
log
|
|
396
398
|
}));
|
|
397
399
|
}
|
|
@@ -474,6 +476,7 @@ export async function playPersonaRun({
|
|
|
474
476
|
journal,
|
|
475
477
|
inFlight = null,
|
|
476
478
|
makeDriver,
|
|
479
|
+
brief = null,
|
|
477
480
|
log
|
|
478
481
|
}) {
|
|
479
482
|
const runId = entry.run_id;
|
|
@@ -486,7 +489,7 @@ export async function playPersonaRun({
|
|
|
486
489
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
487
490
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
488
491
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
489
|
-
const driver = await makeDriver(driverName, { headless });
|
|
492
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
490
493
|
// The ledger is created here rather than inside the loop so the heartbeat
|
|
491
494
|
// can feed it the server's own figure for what this run has spent. Prices
|
|
492
495
|
// are server side only, so that answer is the only real one.
|
|
@@ -535,7 +538,9 @@ export async function playPersonaRun({
|
|
|
535
538
|
target,
|
|
536
539
|
runDirectory: directory,
|
|
537
540
|
skill,
|
|
538
|
-
|
|
541
|
+
// The brief this command fetched and checked is complete, not the
|
|
542
|
+
// pack's copy, which a pack bundle does not always carry.
|
|
543
|
+
brief: brief || pack.brief || null,
|
|
539
544
|
intentNotes: pack.intent_notes || [],
|
|
540
545
|
schemas: pack.schemas || [],
|
|
541
546
|
maxActions,
|
|
@@ -587,10 +592,10 @@ export async function playPersonaRun({
|
|
|
587
592
|
* Upload the run directory and complete the run, through cli-core's
|
|
588
593
|
* `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
|
|
589
594
|
*
|
|
590
|
-
* A
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* the
|
|
595
|
+
* A cancelled run sends no `report`. A budget-capped run sends the partial
|
|
596
|
+
* report built from its notes (`partial: true`, placeholder sentiment the
|
|
597
|
+
* report itself marks as no verdict), because the notes are what the run
|
|
598
|
+
* bought before the money ran out.
|
|
594
599
|
*
|
|
595
600
|
* A refused delivery does not take the job down. `finish` still settles it, the
|
|
596
601
|
* artifacts are on disk, and `resume` can retry.
|
|
@@ -632,15 +637,27 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
632
637
|
}
|
|
633
638
|
|
|
634
639
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
640
|
+
let answer;
|
|
641
|
+
try {
|
|
642
|
+
answer = await deps.finalizeRun(directory, {
|
|
643
|
+
api,
|
|
644
|
+
gameId,
|
|
645
|
+
jobId,
|
|
646
|
+
runId,
|
|
647
|
+
includeVideo: Boolean(options.uploadVideo),
|
|
648
|
+
includeTranscript: Boolean(options.uploadTranscript),
|
|
649
|
+
body
|
|
650
|
+
});
|
|
651
|
+
} catch (error) {
|
|
652
|
+
// A server older than partial reports refuses one as `invalid_report`.
|
|
653
|
+
// The uploads are already done by then, so the run is completed
|
|
654
|
+
// without it rather than left open: the notes stay on disk in
|
|
655
|
+
// report.json either way.
|
|
656
|
+
if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
|
|
657
|
+
log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
|
|
658
|
+
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
659
|
+
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
660
|
+
}
|
|
644
661
|
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
645
662
|
if (answer && answer.dropped_findings > 0) {
|
|
646
663
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
@@ -919,12 +936,41 @@ export async function resolveSetup(deps, options, log) {
|
|
|
919
936
|
};
|
|
920
937
|
}
|
|
921
938
|
|
|
922
|
-
|
|
939
|
+
/**
|
|
940
|
+
* What a driver is created with. The playtest identity is the run's own: the
|
|
941
|
+
* Godot driver passes it into the game's environment and the web driver into
|
|
942
|
+
* the page, and the game's SDK sends the token on `POST /session`, which is
|
|
943
|
+
* what makes the server force the device id and mark the session synthetic.
|
|
944
|
+
* Without it the game's own events from a playtest count as a real player.
|
|
945
|
+
*
|
|
946
|
+
* @returns {Object}
|
|
947
|
+
*/
|
|
948
|
+
export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
|
|
949
|
+
return {
|
|
950
|
+
headless,
|
|
951
|
+
playtest: {
|
|
952
|
+
token: entry.playtest_token || null,
|
|
953
|
+
runId: entry.run_id,
|
|
954
|
+
jobId,
|
|
955
|
+
persona: entry.persona || null
|
|
956
|
+
},
|
|
957
|
+
projectDir: driverName === 'godot_driver' ? target : undefined,
|
|
958
|
+
runDir: directory
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The persona reports on disk, for the aggregate. A partial report (written
|
|
964
|
+
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
|
965
|
+
* run: its findings count, and the aggregate says the job is partial.
|
|
966
|
+
*/
|
|
967
|
+
export async function loadRunReports(repoRoot, jobId, personaRuns) {
|
|
923
968
|
const out = [];
|
|
924
969
|
for (const entry of personaRuns) {
|
|
925
970
|
const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
|
|
926
971
|
try {
|
|
927
|
-
|
|
972
|
+
const report = JSON.parse(await readFile(file, 'utf8'));
|
|
973
|
+
out.push({ runId: entry.run_id, persona: entry.persona, state: report && report.partial === true ? 'budget_exceeded' : 'succeeded', report });
|
|
928
974
|
} catch {
|
|
929
975
|
out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null });
|
|
930
976
|
}
|
package/src/run/aggregate.js
CHANGED
|
@@ -344,7 +344,9 @@ export async function buildAggregate({
|
|
|
344
344
|
const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
|
|
345
345
|
const ranked = rankIssues(issues, personasRun);
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
// A run whose report is partial (the CLI wrote it from the notes when the
|
|
348
|
+
// money ran out) counts toward the issues, and makes the job partial.
|
|
349
|
+
const partial = runs.some(run => !run.report || run.report.partial === true || ['failed', 'canceled', 'budget_exceeded'].includes(run.state));
|
|
348
350
|
const judgement = aggregateRunId
|
|
349
351
|
? await judgeAggregate({ client, jobId, runId: aggregateRunId, ranked, inputs, brief, intentNotes, ledger, log })
|
|
350
352
|
: null;
|
|
@@ -374,8 +376,11 @@ export async function buildAggregate({
|
|
|
374
376
|
state: run.state,
|
|
375
377
|
actions_taken: run.report.actions_taken,
|
|
376
378
|
quit_reason: run.report.quit_reason,
|
|
377
|
-
|
|
378
|
-
|
|
379
|
+
// A partial report's sentiment is the schema's placeholder, not a
|
|
380
|
+
// persona verdict, so it is not shown as one.
|
|
381
|
+
sentiment: run.report.partial ? null : run.report.sentiment,
|
|
382
|
+
would_continue: run.report.partial ? null : run.report.would_continue,
|
|
383
|
+
...(run.report.partial ? { partial: true } : {})
|
|
379
384
|
})),
|
|
380
385
|
issues: applied.issues,
|
|
381
386
|
praise: ranked.praise,
|
|
@@ -450,7 +455,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
450
455
|
'',
|
|
451
456
|
`issues:\n${ranked.issues.map(issue => ` ${issue.issue_id} [${issue.severity}/${issue.category}] ${issue.title}: ${String(issue.description || '').slice(0, 300)} (personas: ${issue.personas_affected.join(', ') || 'none'})`).join('\n') || ' (none)'}`,
|
|
452
457
|
`praise:\n${ranked.praise.map(issue => ` ${issue.issue_id} ${issue.title}`).join('\n') || ' (none)'}`,
|
|
453
|
-
`runs:\n${inputs.map(run => ` ${run.persona}: ${run.report.actions_taken} actions, ${run.report.quit_reason}, would_continue=${run.report.would_continue}`).join('\n') || ' (none)'}`
|
|
458
|
+
`runs:\n${inputs.map(run => ` ${run.persona}: ${run.report.actions_taken} actions, ${run.report.quit_reason}, ${run.report.partial ? 'partial (out of budget, no verdict)' : `would_continue=${run.report.would_continue}`}`).join('\n') || ' (none)'}`
|
|
454
459
|
].join('\n');
|
|
455
460
|
|
|
456
461
|
try {
|
|
@@ -582,7 +587,7 @@ export function renderAggregateMarkdown(json) {
|
|
|
582
587
|
lines.push('|---|---|---|---|---|---|---|');
|
|
583
588
|
for (const run of json.runs) {
|
|
584
589
|
const sentiment = run.sentiment || {};
|
|
585
|
-
lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue ? 'yes' : 'no'} |`);
|
|
590
|
+
lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue === null || run.would_continue === undefined ? '-' : run.would_continue ? 'yes' : 'no'} |`);
|
|
586
591
|
}
|
|
587
592
|
lines.push('');
|
|
588
593
|
lines.push('## Top issues (ranked)');
|
package/src/run/args.js
CHANGED
|
@@ -22,7 +22,6 @@ const VALUED = new Map([
|
|
|
22
22
|
['--cli', 'cli'],
|
|
23
23
|
['--max-actions', 'maxActions'],
|
|
24
24
|
['--concurrency', 'concurrency'],
|
|
25
|
-
['--budget-usd', 'budgetUsd'],
|
|
26
25
|
['--quality', 'quality']
|
|
27
26
|
]);
|
|
28
27
|
|
|
@@ -41,7 +40,7 @@ const BOOLEAN = new Map([
|
|
|
41
40
|
]);
|
|
42
41
|
|
|
43
42
|
/** Options whose value is a number. */
|
|
44
|
-
const NUMERIC = new Set(['maxActions', 'concurrency'
|
|
43
|
+
const NUMERIC = new Set(['maxActions', 'concurrency']);
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* @param {string[]} argv the leftovers commander handed over
|
|
@@ -77,6 +76,21 @@ export function parseRunArgv(argv = []) {
|
|
|
77
76
|
return { options, unknown };
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Extra lines for unknown options that used to exist, so a removed flag is
|
|
81
|
+
* explained rather than just refused.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} unknown
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function unknownOptionNotes(unknown = []) {
|
|
87
|
+
const notes = [];
|
|
88
|
+
if (unknown.includes('--budget-usd')) {
|
|
89
|
+
notes.push('--budget-usd was removed: the model budget is set by the server for each run, from what the job was charged, and enforced at the proxy.');
|
|
90
|
+
}
|
|
91
|
+
return notes;
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
/**
|
|
81
95
|
* Merge the flags cli-core parsed with the ones this file parsed, flags
|
|
82
96
|
* winning, and normalise the two names for the repository root.
|
|
@@ -115,4 +129,4 @@ function stripUndefined(value) {
|
|
|
115
129
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
export default { parseRunArgv, optionsFrom };
|
|
132
|
+
export default { parseRunArgv, optionsFrom, unknownOptionNotes };
|
package/src/run/context.js
CHANGED
|
@@ -31,6 +31,13 @@ export const MAX_IMAGES_IN_CONTEXT = 1;
|
|
|
31
31
|
/** How many full observations stay in context (spec 03's K). */
|
|
32
32
|
export const KEEP_OBSERVATIONS = 6;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What an observation identical to the one before it is sent as. The driver
|
|
36
|
+
* marks it (`observation.unchanged`) and ./tools.js writes this in place of
|
|
37
|
+
* the screen, keeping the `step N` line so it is still an observation.
|
|
38
|
+
*/
|
|
39
|
+
export const UNCHANGED_OBSERVATION_TEXT = '(unchanged)';
|
|
40
|
+
|
|
34
41
|
const DROPPED_IMAGE_TEXT = '[screenshot dropped from context; only the newest screen is kept]';
|
|
35
42
|
const DROPPED_OBSERVATION_TEXT = '[older observation dropped from context; ask for a snapshot if you need to look again]';
|
|
36
43
|
|
|
@@ -138,7 +145,11 @@ export function pruneObservations(messages, keep = KEEP_OBSERVATIONS) {
|
|
|
138
145
|
message.content.forEach((block, j) => {
|
|
139
146
|
if (block && block.type === 'tool_result' && Array.isArray(block.content)) {
|
|
140
147
|
const first = block.content.find(child => child && child.type === 'text');
|
|
141
|
-
|
|
148
|
+
// An unchanged marker is not counted: it is a few bytes, and counting
|
|
149
|
+
// it would let a run of identical screens push the last REAL screen
|
|
150
|
+
// out of context, leaving the persona nothing to act on.
|
|
151
|
+
const text = String(first && first.text);
|
|
152
|
+
if (first && /^(?:[^\n]*\n)?step \d+/.test(text) && !text.endsWith(`\n${UNCHANGED_OBSERVATION_TEXT}`)) indexes.push([i, j]);
|
|
142
153
|
}
|
|
143
154
|
});
|
|
144
155
|
});
|
package/src/run/drivers/web.js
CHANGED
|
@@ -2,7 +2,6 @@ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
DriverError,
|
|
5
|
-
OBSERVATION_MAX_CHARS,
|
|
6
5
|
THIN_OBSERVATION_CHARS,
|
|
7
6
|
observationHash,
|
|
8
7
|
screenshotName,
|
|
@@ -36,6 +35,87 @@ export const MAX_WAIT_MS = 10000;
|
|
|
36
35
|
/** How long a single Playwright call may take before it is an error, not a hang. */
|
|
37
36
|
export const ACTION_TIMEOUT_MS = 15000;
|
|
38
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The web observation ceiling. An aria snapshot is re-sent on every turn the
|
|
40
|
+
* observation stays in context, and the run's model budget is fixed, so a busy
|
|
41
|
+
* page at the shared 12,000 character ceiling spends the budget on markup the
|
|
42
|
+
* persona never needed. Compaction (below) usually lands well under this.
|
|
43
|
+
*/
|
|
44
|
+
export const WEB_OBSERVATION_MAX_CHARS = 4000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Full observations kept in context for a web run. The newest screen is what
|
|
48
|
+
* the persona acts on; one more is enough to compare against, and every older
|
|
49
|
+
* one is a stub.
|
|
50
|
+
*/
|
|
51
|
+
export const WEB_KEEP_OBSERVATIONS = 2;
|
|
52
|
+
|
|
53
|
+
/** Roles a player can act on. Kept even with no name, since they are still clickable. */
|
|
54
|
+
const INTERACTIVE_ROLES = new Set([
|
|
55
|
+
'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'combobox', 'listbox', 'option',
|
|
56
|
+
'slider', 'spinbutton', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
|
|
57
|
+
'canvas'
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keep the nodes a player could act on or read, and drop the structure in
|
|
62
|
+
* between. An aria snapshot prints every wrapper (`- generic:`, `- list:`, a
|
|
63
|
+
* nameless `- img`) and the attribute lines under links (`- /url: ...`), none
|
|
64
|
+
* of which tell a persona anything, and all of which cost tokens every turn.
|
|
65
|
+
*
|
|
66
|
+
* Indentation is dropped too: the persona targets nodes by role and name, not
|
|
67
|
+
* by depth. Text that is not an aria snapshot (the innerText fallback for a
|
|
68
|
+
* thin page) is returned unchanged.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} text
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function compactAriaSnapshot(text) {
|
|
74
|
+
const value = String(text == null ? '' : text);
|
|
75
|
+
const lines = value.split('\n');
|
|
76
|
+
if (!lines.some(line => /^\s*- /.test(line))) return value;
|
|
77
|
+
const kept = [];
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed.startsWith('- ')) continue;
|
|
81
|
+
const body = unquoteKey(trimmed.slice(2));
|
|
82
|
+
if (body.startsWith('/')) continue;
|
|
83
|
+
const role = (body.match(/^([a-z]+)/) || [])[1] || '';
|
|
84
|
+
const named = /^[a-z]+ "[^"]*"/.test(body);
|
|
85
|
+
const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
|
|
86
|
+
if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
|
|
87
|
+
}
|
|
88
|
+
return kept.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Playwright writes a node key as a single quoted YAML scalar when the name
|
|
93
|
+
* contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
|
|
94
|
+
* that outer pair (a doubled `''` inside is one quote) so the node is
|
|
95
|
+
* classified and shown like any other, keeping whatever followed the closing
|
|
96
|
+
* quote (a `:` for a node with children, or `: text`).
|
|
97
|
+
*
|
|
98
|
+
* @param {string} body the line after its `- `
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function unquoteKey(body) {
|
|
102
|
+
if (!body.startsWith("'")) return body;
|
|
103
|
+
let out = '';
|
|
104
|
+
for (let i = 1; i < body.length; i += 1) {
|
|
105
|
+
if (body[i] === "'") {
|
|
106
|
+
if (body[i + 1] === "'") {
|
|
107
|
+
out += "'";
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
return out + body.slice(i + 1);
|
|
112
|
+
}
|
|
113
|
+
out += body[i];
|
|
114
|
+
}
|
|
115
|
+
// No closing quote: not a quoted key after all.
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
|
|
39
119
|
const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
|
|
40
120
|
|
|
41
121
|
/**
|
|
@@ -127,12 +207,33 @@ export function resolveLocator(page, target) {
|
|
|
127
207
|
return page.getByText(value, { exact: false }).first();
|
|
128
208
|
}
|
|
129
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Hand the run's playtest identity to the page. Runs in the browser, before
|
|
212
|
+
* any of the game's own scripts, so the Ravensight JS SDK finds it at
|
|
213
|
+
* construction. Serialised by Playwright, so it must not close over anything.
|
|
214
|
+
*
|
|
215
|
+
* @param {{token: string, runId: string, jobId: string, persona: string}} identity
|
|
216
|
+
*/
|
|
217
|
+
function injectPlaytestIdentity(identity) {
|
|
218
|
+
window.__RAVENSIGHT_PLAYTEST__ = identity;
|
|
219
|
+
try {
|
|
220
|
+
localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
|
|
221
|
+
} catch {
|
|
222
|
+
// Storage can be refused (a sandboxed frame, a privacy mode). The window
|
|
223
|
+
// global is the first thing the SDK reads, so this is only the fallback.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
130
227
|
/**
|
|
131
228
|
* @param {Object} opts
|
|
132
229
|
* @param {(specifier: string) => Promise<any>} [opts.importer] test seam
|
|
230
|
+
* @param {boolean} [opts.headless]
|
|
231
|
+
* @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
|
|
232
|
+
* the run's playtest identity, handed to the page so the game's own SDK
|
|
233
|
+
* session is marked synthetic
|
|
133
234
|
* @returns {Object} a driver
|
|
134
235
|
*/
|
|
135
|
-
export function createWebDriver({ importer, headless = true } = {}) {
|
|
236
|
+
export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
|
|
136
237
|
let browser = null;
|
|
137
238
|
let context = null;
|
|
138
239
|
let page = null;
|
|
@@ -142,6 +243,7 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
142
243
|
let step = 0;
|
|
143
244
|
let shots = 0;
|
|
144
245
|
let lastHash = null;
|
|
246
|
+
let lastObservedHash = null;
|
|
145
247
|
const consoleBuffer = [];
|
|
146
248
|
|
|
147
249
|
/** @returns {any} */
|
|
@@ -150,9 +252,15 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
150
252
|
return page;
|
|
151
253
|
}
|
|
152
254
|
|
|
255
|
+
/**
|
|
256
|
+
* @returns {Promise<{text: string, aria: boolean}>} the screen text, and
|
|
257
|
+
* whether it is the aria snapshot (compacted) or the innerText fallback
|
|
258
|
+
* (never compacted: a bullet list in the page is not snapshot structure)
|
|
259
|
+
*/
|
|
153
260
|
async function snapshotText() {
|
|
154
261
|
const current = requirePage();
|
|
155
262
|
let text = '';
|
|
263
|
+
let aria = true;
|
|
156
264
|
const body = current.locator('body');
|
|
157
265
|
if (typeof body.ariaSnapshot === 'function') {
|
|
158
266
|
try {
|
|
@@ -167,17 +275,31 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
167
275
|
const el = globalThis.document && globalThis.document.body;
|
|
168
276
|
return el ? el.innerText : '';
|
|
169
277
|
});
|
|
170
|
-
if (String(inner).trim().length > text.trim().length)
|
|
278
|
+
if (String(inner).trim().length > text.trim().length) {
|
|
279
|
+
text = String(inner);
|
|
280
|
+
aria = false;
|
|
281
|
+
}
|
|
171
282
|
} catch {
|
|
172
283
|
// A page mid-navigation has no body to read. The thin observation
|
|
173
284
|
// is the honest answer, and the screenshot fallback below covers it.
|
|
174
285
|
}
|
|
175
286
|
}
|
|
176
|
-
return text;
|
|
287
|
+
return { text, aria };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What the model is shown for a raw snapshot. `observe` and `act` both use
|
|
292
|
+
* this, so the two hashes compare like with like.
|
|
293
|
+
* @param {{text: string, aria: boolean}} snap
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
function shownText(snap) {
|
|
297
|
+
return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
|
|
177
298
|
}
|
|
178
299
|
|
|
179
300
|
return {
|
|
180
301
|
name: 'playwright_web',
|
|
302
|
+
keepObservations: WEB_KEEP_OBSERVATIONS,
|
|
181
303
|
|
|
182
304
|
/**
|
|
183
305
|
* @param {Object} opts
|
|
@@ -200,6 +322,18 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
200
322
|
viewport,
|
|
201
323
|
...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
|
|
202
324
|
});
|
|
325
|
+
// The playtest identity goes in as an init script, not as
|
|
326
|
+
// `extraHTTPHeaders`: a context header rides on every request the page
|
|
327
|
+
// makes, including to the game's own API, and the token is only for
|
|
328
|
+
// Ravensight. The SDK reads it from the page and sends it itself.
|
|
329
|
+
if (playtest && playtest.token) {
|
|
330
|
+
await context.addInitScript(injectPlaytestIdentity, {
|
|
331
|
+
token: playtest.token,
|
|
332
|
+
runId: playtest.runId || null,
|
|
333
|
+
jobId: playtest.jobId || null,
|
|
334
|
+
persona: playtest.persona || null
|
|
335
|
+
});
|
|
336
|
+
}
|
|
203
337
|
page = await context.newPage();
|
|
204
338
|
page.on('console', message => {
|
|
205
339
|
if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
|
|
@@ -219,10 +353,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
219
353
|
const current = requirePage();
|
|
220
354
|
const started = Date.now();
|
|
221
355
|
step += 1;
|
|
222
|
-
const
|
|
356
|
+
const snap = await snapshotText();
|
|
357
|
+
const text = snap.text;
|
|
223
358
|
const observation = {
|
|
224
359
|
step,
|
|
225
|
-
text:
|
|
360
|
+
text: shownText(snap),
|
|
226
361
|
url: current.url(),
|
|
227
362
|
elapsedMs: Date.now() - started
|
|
228
363
|
};
|
|
@@ -244,6 +379,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
244
379
|
// point would otherwise read as changed to `act` and identical to
|
|
245
380
|
// anything reading the observation.
|
|
246
381
|
lastHash = observationHash(observation);
|
|
382
|
+
// The same screen twice is sent as a marker rather than in full
|
|
383
|
+
// (./tools.js), which is most of what a stuck persona would otherwise
|
|
384
|
+
// pay for.
|
|
385
|
+
if (lastHash === lastObservedHash) observation.unchanged = true;
|
|
386
|
+
lastObservedHash = lastHash;
|
|
247
387
|
return observation;
|
|
248
388
|
},
|
|
249
389
|
|
|
@@ -293,9 +433,9 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
293
433
|
try {
|
|
294
434
|
// The same truncation observe() applies, so the two hashes are
|
|
295
435
|
// comparable. Without it a long screen is always "changed".
|
|
296
|
-
const
|
|
297
|
-
const after = { text };
|
|
298
|
-
if (text.trim().length < THIN_OBSERVATION_CHARS) {
|
|
436
|
+
const snap = await snapshotText();
|
|
437
|
+
const after = { text: shownText(snap) };
|
|
438
|
+
if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
299
439
|
const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
300
440
|
after.screenshotBase64 = frame.toString('base64');
|
|
301
441
|
}
|
package/src/run/personaLoop.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
5
5
|
import { observationHash } from './drivers/driver.js';
|
|
6
|
+
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
6
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
7
8
|
import { RUN_FILES } from './paths.js';
|
|
8
9
|
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
@@ -32,11 +33,30 @@ import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
|
32
33
|
* up, so the report (which is the product) gets written while there is
|
|
33
34
|
* still budget to write it with.
|
|
34
35
|
* - A 402 means the money is gone. Nothing further is asked of the model,
|
|
35
|
-
* including the report: a partial `report.md`
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
36
|
+
* including the report: a partial `report.md` and `report.json` are
|
|
37
|
+
* written locally from the notes (`partial: true`, each note an
|
|
38
|
+
* unreviewed finding), `complete` is sent with `state: budget_exceeded`
|
|
39
|
+
* and that report, and the process exits 2. The notes are the one thing
|
|
40
|
+
* the run bought before the money ran out, so they are not thrown away.
|
|
38
41
|
*/
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Order across notes and screenshots, so a partial report can attach the
|
|
45
|
+
* screenshot nearest to each note. Module wide and only ever compared within
|
|
46
|
+
* one run, where it is monotonic.
|
|
47
|
+
*/
|
|
48
|
+
let ORDER = 0;
|
|
49
|
+
const nextOrder = () => {
|
|
50
|
+
ORDER += 1;
|
|
51
|
+
return ORDER;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** `Failed to load resource: ... status of 4xx`: a missing asset or a refused call, not a crash. */
|
|
55
|
+
const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d/;
|
|
56
|
+
|
|
57
|
+
/** A persona note kind as a report finding category. */
|
|
58
|
+
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
59
|
+
|
|
40
60
|
/** Stop injecting the "nothing changed" hint after this many identical screens. */
|
|
41
61
|
export const STUCK_HINT_AFTER = 5;
|
|
42
62
|
|
|
@@ -89,7 +109,9 @@ export async function runPersona(args) {
|
|
|
89
109
|
schemas,
|
|
90
110
|
maxActions,
|
|
91
111
|
maxImages = MAX_IMAGES_IN_CONTEXT,
|
|
92
|
-
|
|
112
|
+
// A driver may say how many observations it needs in context: a web
|
|
113
|
+
// snapshot is big and re-sent every turn, a Godot scene summary is not.
|
|
114
|
+
keepObservations = (driver && Number.isInteger(driver.keepObservations) ? driver.keepObservations : KEEP_OBSERVATIONS),
|
|
93
115
|
heartbeat = null,
|
|
94
116
|
onProgress = () => {},
|
|
95
117
|
log = () => {},
|
|
@@ -188,7 +210,15 @@ export async function runPersona(args) {
|
|
|
188
210
|
outcome.quitReason = 'cost_cap';
|
|
189
211
|
// Nothing more is asked of the model: there is no budget left to ask
|
|
190
212
|
// with, and a refused call would just be another 402.
|
|
191
|
-
|
|
213
|
+
// A report that cannot be written (a secret shaped note, a full disk)
|
|
214
|
+
// must not turn an out of budget run into a failed one.
|
|
215
|
+
try {
|
|
216
|
+
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
|
|
217
|
+
} catch (writeError) {
|
|
218
|
+
outcome.report = null;
|
|
219
|
+
outcome.reportMarkdown = null;
|
|
220
|
+
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
221
|
+
}
|
|
192
222
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
193
223
|
}
|
|
194
224
|
|
|
@@ -230,6 +260,14 @@ export async function runPersona(args) {
|
|
|
230
260
|
if (error instanceof ProxyError && error.isBudget) {
|
|
231
261
|
outcome.state = 'budget_exceeded';
|
|
232
262
|
outcome.quitReason = 'cost_cap';
|
|
263
|
+
// The money ran out in the report step itself: the notes are still
|
|
264
|
+
// worth keeping, by the same rule as a 402 during play.
|
|
265
|
+
try {
|
|
266
|
+
const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
|
|
267
|
+
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
|
|
268
|
+
} catch (writeError) {
|
|
269
|
+
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
270
|
+
}
|
|
233
271
|
} else if (error instanceof ProxyError && error.isClosed) {
|
|
234
272
|
outcome.state = 'canceled';
|
|
235
273
|
outcome.quitReason = 'error';
|
|
@@ -420,11 +458,11 @@ async function playLoop({
|
|
|
420
458
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
421
459
|
// overwrite the transcript line's `kind`, and every note would
|
|
422
460
|
// read as a different sort of line.
|
|
423
|
-
notes.push({ ...note, at_action: actions });
|
|
461
|
+
notes.push({ ...note, at_action: actions, order: nextOrder() });
|
|
424
462
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
425
463
|
},
|
|
426
464
|
onScreenshot: shot => {
|
|
427
|
-
screenshots.push(shot);
|
|
465
|
+
screenshots.push({ ...shot, order: nextOrder() });
|
|
428
466
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
429
467
|
}
|
|
430
468
|
});
|
|
@@ -466,13 +504,17 @@ async function playLoop({
|
|
|
466
504
|
|
|
467
505
|
const consoleErrors = typeof driver.consoleErrors === 'function' ? await driver.consoleErrors() : [];
|
|
468
506
|
if (consoleErrors.length > 0) {
|
|
469
|
-
// An uncaught error in the game is
|
|
507
|
+
// An uncaught error in the game is worth recording whether or not the
|
|
470
508
|
// persona noticed it, so it is logged and evidenced automatically
|
|
471
|
-
// (spec 03). It
|
|
509
|
+
// (spec 03). It carries no severity: the report step decides that,
|
|
510
|
+
// with the persona's view of the screen in front of it. A 4xx on a
|
|
511
|
+
// resource is a `network` note, which the report step reads as context
|
|
512
|
+
// rather than as a bug (a missing favicon is not a broken game).
|
|
472
513
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
473
514
|
for (const entry of consoleErrors) {
|
|
474
|
-
const
|
|
475
|
-
|
|
515
|
+
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
516
|
+
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
|
|
517
|
+
notes.push({ ...note, at_action: actions, order: nextOrder() });
|
|
476
518
|
transcript.write({ kind: 'note', note });
|
|
477
519
|
}
|
|
478
520
|
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}.` : ''}`);
|
|
@@ -560,6 +602,8 @@ async function reportPhase({
|
|
|
560
602
|
'Notes you logged, in order:',
|
|
561
603
|
notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
|
|
562
604
|
'',
|
|
605
|
+
'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.',
|
|
606
|
+
'',
|
|
563
607
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
564
608
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
565
609
|
'',
|
|
@@ -685,7 +729,10 @@ async function reportPhase({
|
|
|
685
729
|
async function callWithImageRetry({ imageBudget, keepObservations, messages, onImageBudget, ...rest }) {
|
|
686
730
|
let budget = imageBudget;
|
|
687
731
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
688
|
-
|
|
732
|
+
// One moving cache breakpoint on the newest block, as the fix loop does:
|
|
733
|
+
// the prefix up to it is what the next turn re-sends, so the next turn
|
|
734
|
+
// reads it from the cache instead of paying for it again.
|
|
735
|
+
const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: budget, keepObservations }));
|
|
689
736
|
try {
|
|
690
737
|
return await callStepWithRetry({ ...rest, messages: prepared });
|
|
691
738
|
} catch (error) {
|
|
@@ -801,7 +848,7 @@ function redactToolInput(toolUse) {
|
|
|
801
848
|
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
802
849
|
try {
|
|
803
850
|
const shot = await driver.screenshot(name);
|
|
804
|
-
screenshots.push({ ...shot, reason: name });
|
|
851
|
+
screenshots.push({ ...shot, reason: name, order: nextOrder() });
|
|
805
852
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
806
853
|
return shot;
|
|
807
854
|
} catch (error) {
|
|
@@ -893,6 +940,104 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
893
940
|
].join('\n');
|
|
894
941
|
}
|
|
895
942
|
|
|
943
|
+
/**
|
|
944
|
+
* The screenshot nearest a note, by the order they happened in; a tie goes
|
|
945
|
+
* to the later one, since a persona usually notes something and then
|
|
946
|
+
* captures it.
|
|
947
|
+
*
|
|
948
|
+
* @param {Object} note
|
|
949
|
+
* @param {Array<Object>} screenshots
|
|
950
|
+
* @returns {Object|null}
|
|
951
|
+
*/
|
|
952
|
+
export function nearestScreenshot(note, screenshots) {
|
|
953
|
+
let best = null;
|
|
954
|
+
let bestDistance = Infinity;
|
|
955
|
+
for (const shot of screenshots) {
|
|
956
|
+
if (!shot || !shot.relativePath || !Number.isFinite(shot.order) || !Number.isFinite(note.order)) continue;
|
|
957
|
+
const distance = Math.abs(shot.order - note.order);
|
|
958
|
+
if (distance < bestDistance || (distance === bestDistance && best && shot.order > best.order)) {
|
|
959
|
+
best = shot;
|
|
960
|
+
bestDistance = distance;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return best || screenshots.find(shot => shot && shot.relativePath) || null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The persona's notes as report findings, for a run with no report step.
|
|
968
|
+
*
|
|
969
|
+
* Nothing is judged here, because there is no model turn to judge with:
|
|
970
|
+
* the persona's own kind becomes the category, its own severity is kept
|
|
971
|
+
* when it gave one and is `minor` otherwise (`praise` for a `good` note), and
|
|
972
|
+
* a network note is left out, since it is context rather than a finding.
|
|
973
|
+
*
|
|
974
|
+
* @param {Array<Object>} notes
|
|
975
|
+
* @param {Array<Object>} screenshots
|
|
976
|
+
* @returns {Array<Object>}
|
|
977
|
+
*/
|
|
978
|
+
export function notesToFindings(notes, screenshots) {
|
|
979
|
+
const findings = [];
|
|
980
|
+
for (const note of notes) {
|
|
981
|
+
const category = NOTE_CATEGORY[note.kind];
|
|
982
|
+
if (!category || !note.text) continue;
|
|
983
|
+
const text = String(note.text);
|
|
984
|
+
const shot = nearestScreenshot(note, screenshots);
|
|
985
|
+
findings.push({
|
|
986
|
+
id: `F${String(findings.length + 1).padStart(3, '0')}`,
|
|
987
|
+
severity: note.kind === 'good' ? 'praise' : (note.severity && note.severity !== 'praise' ? note.severity : 'minor'),
|
|
988
|
+
category,
|
|
989
|
+
title: text.length > 120 ? `${text.slice(0, 117)}...` : text,
|
|
990
|
+
description: text,
|
|
991
|
+
repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
|
|
992
|
+
evidence: shot
|
|
993
|
+
? [{ type: 'screenshot', ref: shot.relativePath }]
|
|
994
|
+
: [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return findings;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The report for a run the money ran out on: the notes as findings,
|
|
1002
|
+
* `partial: true`, and both files written. The sentiment is the scale's
|
|
1003
|
+
* midpoint and `would_continue` is false, because the schema requires both
|
|
1004
|
+
* and no persona verdict was recorded; `partial` is what tells a reader not
|
|
1005
|
+
* to read them as one.
|
|
1006
|
+
*/
|
|
1007
|
+
async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
|
|
1008
|
+
const report = stampReport({
|
|
1009
|
+
findings: notesToFindings(notes, screenshots),
|
|
1010
|
+
sentiment: { fun: 3, clarity: 3, frustration: 3 },
|
|
1011
|
+
would_continue: false,
|
|
1012
|
+
partial: true
|
|
1013
|
+
}, {
|
|
1014
|
+
runId,
|
|
1015
|
+
jobId,
|
|
1016
|
+
persona: persona.slug,
|
|
1017
|
+
driver: driverName,
|
|
1018
|
+
target,
|
|
1019
|
+
startedAt,
|
|
1020
|
+
endedAt,
|
|
1021
|
+
actionsTaken: play.actionsTaken,
|
|
1022
|
+
usage: ledger.toReportUsage(),
|
|
1023
|
+
promptVersion: promptVersionOf(skill) || 'unknown',
|
|
1024
|
+
quitReason: 'cost_cap',
|
|
1025
|
+
quitDetail: play.quitDetail
|
|
1026
|
+
});
|
|
1027
|
+
const markdown = renderFallbackMarkdown({
|
|
1028
|
+
persona,
|
|
1029
|
+
driverName,
|
|
1030
|
+
target,
|
|
1031
|
+
notes,
|
|
1032
|
+
screenshots,
|
|
1033
|
+
play,
|
|
1034
|
+
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1035
|
+
});
|
|
1036
|
+
await writeReportFiles({ runDirectory, markdown, report });
|
|
1037
|
+
outcome.report = report;
|
|
1038
|
+
outcome.reportMarkdown = markdown;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
896
1041
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
897
1042
|
const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
|
|
898
1043
|
assertNoSecrets('report.md', markdown);
|
|
@@ -911,4 +1056,4 @@ export function promptVersionOf(skill) {
|
|
|
911
1056
|
return match ? match[1] : null;
|
|
912
1057
|
}
|
|
913
1058
|
|
|
914
|
-
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown };
|
|
1059
|
+
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
|
package/src/run/tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
|
|
1
2
|
import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -300,13 +301,17 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
300
301
|
* @param {boolean} [isError]
|
|
301
302
|
*/
|
|
302
303
|
export function observationResult(id, observation, prefix = '', isError = false) {
|
|
304
|
+
// The same screen as last time is a marker, not a resend: the persona
|
|
305
|
+
// already has it in context, and a stuck run would otherwise pay for the
|
|
306
|
+
// whole snapshot (and its image) on every turn it stays stuck.
|
|
307
|
+
const body = observation.unchanged ? UNCHANGED_OBSERVATION_TEXT : truncateObservation(observation.text, OBSERVATION_MAX_CHARS);
|
|
303
308
|
const content = [
|
|
304
309
|
{
|
|
305
310
|
type: 'text',
|
|
306
|
-
text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${
|
|
311
|
+
text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${body}`
|
|
307
312
|
}
|
|
308
313
|
];
|
|
309
|
-
if (observation.screenshotBase64) {
|
|
314
|
+
if (observation.screenshotBase64 && !observation.unchanged) {
|
|
310
315
|
content.push({
|
|
311
316
|
type: 'image',
|
|
312
317
|
source: {
|
package/src/ui/index.js
CHANGED
|
@@ -157,6 +157,33 @@ export async function confirm(question, options = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Ask the person to pick one of a numbered list. Like `confirm`, it never
|
|
162
|
+
* asks without a terminal: with no TTY (CI, a pipe) it answers null at once,
|
|
163
|
+
* and so does an answer that is not one of the numbers shown.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} question e.g. "Which game? [1-3]"
|
|
166
|
+
* @param {Array<string>} labels one line per choice, printed numbered from 1
|
|
167
|
+
* @param {{input?: object, output?: object}} [options]
|
|
168
|
+
* @returns {Promise<number|null>} the zero based index, or null
|
|
169
|
+
*/
|
|
170
|
+
export async function choose(question, labels, options = {}) {
|
|
171
|
+
const input = options.input || process.stdin;
|
|
172
|
+
if (!input.isTTY) return null;
|
|
173
|
+
const output = options.output || stream;
|
|
174
|
+
|
|
175
|
+
labels.forEach((label, index) => output.write(` ${index + 1}. ${label}\n`));
|
|
176
|
+
const rl = createInterface({ input, output });
|
|
177
|
+
try {
|
|
178
|
+
const answer = (await rl.question(`${question} `)).trim();
|
|
179
|
+
if (!/^\d+$/.test(answer)) return null;
|
|
180
|
+
const index = Number(answer) - 1;
|
|
181
|
+
return index >= 0 && index < labels.length ? index : null;
|
|
182
|
+
} finally {
|
|
183
|
+
rl.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
/** Cents as dollars, the one place that formatting exists. */
|
|
161
188
|
export function money(cents) {
|
|
162
189
|
if (cents === null || cents === undefined) return 'unknown';
|
|
@@ -190,6 +217,7 @@ export const ui = {
|
|
|
190
217
|
table,
|
|
191
218
|
spinner,
|
|
192
219
|
confirm,
|
|
220
|
+
choose,
|
|
193
221
|
money,
|
|
194
222
|
bytes
|
|
195
223
|
};
|