ravensight-playtest 0.2.0 → 0.2.4
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 +30 -1
- package/docs/ADAPTERS.md +77 -0
- package/package.json +2 -1
- package/src/cli.js +3 -2
- package/src/commands/fix.js +8 -2
- package/src/commands/run.js +12 -3
- package/src/dashboard.js +15 -5
- package/src/run/actionSelector.js +47 -0
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +6 -3
- package/src/run/drivers/index.js +6 -6
- package/src/run/drivers/stdio.js +111 -0
- package/src/run/drivers/web.js +7 -1
- package/src/run/drivers/webdriver.js +67 -0
- package/src/run/fixLoop.js +125 -17
- package/src/run/personaLoop.js +45 -2
- package/src/run/repoTools.js +98 -6
- package/src/run/resume.js +1 -0
- package/src/run/tools.js +12 -1
- package/src/states.js +4 -1
package/README.md
CHANGED
|
@@ -367,7 +367,10 @@ In your repo:
|
|
|
367
367
|
```
|
|
368
368
|
|
|
369
369
|
A `fix` job's run directory holds `changeset.json`, `report.md` (the pull
|
|
370
|
-
request body) and `usage.json`, plus the screenshots it was shown as evidence
|
|
370
|
+
request body) and `usage.json`, plus the screenshots it was shown as evidence,
|
|
371
|
+
and a local `transcript.jsonl` of the apply loop (which tool, on what, how
|
|
372
|
+
much came back) that is never uploaded and is how an attempt that wrote
|
|
373
|
+
nothing gets explained afterwards.
|
|
371
374
|
|
|
372
375
|
Add `.ravensight/jobs/` to your `.gitignore`; `init` does it for you when you
|
|
373
376
|
already have one.
|
|
@@ -462,3 +465,29 @@ import { createClient, packs, uploadRunDir } from 'ravensight-playtest';
|
|
|
462
465
|
## License
|
|
463
466
|
|
|
464
467
|
MIT. Copyright Reality Software Entertainment.
|
|
468
|
+
|
|
469
|
+
## Ravensight Playtester action selection
|
|
470
|
+
|
|
471
|
+
The optional `--controller jev` mode uses the existing persona run. A screenshot
|
|
472
|
+
observer proposes actions, Ravensight selects one on the server, and the local
|
|
473
|
+
driver executes only that choice. Ambiguous controls stop the run for review.
|
|
474
|
+
Reports, screenshots and findings use the normal upload and review flow.
|
|
475
|
+
|
|
476
|
+
```sh
|
|
477
|
+
ravensight-playtest run --driver playwright_web --controller jev --build-url https://your-game.example/ --max-actions 12 --headed
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Sign in, initialize the project and save its expectations brief first. The server
|
|
481
|
+
must advertise this controller in its content pack; otherwise the command stops
|
|
482
|
+
before registering a paid job. No provider key belongs in the CLI. Selection costs
|
|
483
|
+
share the existing persona-run budget and do not create another wallet charge.
|
|
484
|
+
Resume preserves the controller recorded in the job journal.
|
|
485
|
+
|
|
486
|
+
For Jump Bunny, use `https://reality-software-entertainment.github.io/poc-jump-bunny/`
|
|
487
|
+
and the public player label **Ravensight Playtester**. This is a local browser run,
|
|
488
|
+
not a hosted browser service. Human playtesting is still needed for subjective
|
|
489
|
+
feedback and timing-sensitive gameplay.
|
|
490
|
+
|
|
491
|
+
## Build adapters
|
|
492
|
+
|
|
493
|
+
Browser and Godot runners share the paid playtest lifecycle with desktop, Unity/Unreal export, Android/iOS and custom JSON-RPC adapters. Native adapters require a locally configured automation server and remain experimental until validated against your build. See [adapter setup and validation boundaries](docs/ADAPTERS.md).
|
package/docs/ADAPTERS.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Connect a game to Ravensight Playtester
|
|
2
|
+
|
|
3
|
+
Run the CLI on the machine that can access your build. Sign in, link the project with `ravensight-playtest init --game <gameId>`, and complete its expectations brief. `run` quotes the existing persona price before registration. Adapters share the same prepaid job, action limits, model budget, cancellation, reports and uploads.
|
|
4
|
+
|
|
5
|
+
| Adapter | Target | Validation |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `playwright_web` | Browser URL, including canvas/WebGL exports | Automated tests and live Jump Bunny gameplay |
|
|
8
|
+
| `godot_driver` | Godot project with the playtest addon | Existing addon protocol and runner tests |
|
|
9
|
+
| `desktop_driver` | Local W3C/Appium session configuration | Protocol tested; experimental on physical builds |
|
|
10
|
+
| `unity_driver`, `unreal_driver` | Desktop exports through that same W3C interface | Engine-neutral screen controls, not editor plugins; experimental |
|
|
11
|
+
| `android_driver`, `ios_driver` | Appium device/emulator/simulator configuration | Protocol tested; hardware/build validation still required |
|
|
12
|
+
| `cli_stdio` | Developer-owned JSON-RPC bridge | Real child-process protocol test |
|
|
13
|
+
|
|
14
|
+
No adapter promises every game is autonomously solvable. Fast action games, unusual controls, anti-cheat, hardware access and protected platforms may require a game-specific bridge. Console tooling and authorization remain the developer's responsibility. The runner cannot bypass platform access restrictions.
|
|
15
|
+
|
|
16
|
+
## Browser and Godot
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
ravensight-playtest run --driver playwright_web --build-url https://your-game.example/ --headed
|
|
20
|
+
ravensight-playtest run --driver godot_driver --godot-project ./your-game
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`--controller jev` enables structured action selection when the server supports it. Screen interpretation proposes controls and the selector chooses a supplied action. Uncertain decisions stop for review. Provider credentials stay on Ravensight. A completed playtest report is evidence for human review, not proof that a game is bug-free.
|
|
24
|
+
|
|
25
|
+
## Desktop and mobile
|
|
26
|
+
|
|
27
|
+
Install and configure an Appium-compatible driver for your platform. Start its server locally. Supply `ravensight-driver.json` with a loopback endpoint and capabilities appropriate to the installed driver:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"endpoint": "http://127.0.0.1:4723",
|
|
32
|
+
"capabilities": {
|
|
33
|
+
"platformName": "Android",
|
|
34
|
+
"appium:automationName": "UiAutomator2",
|
|
35
|
+
"appium:deviceName": "YOUR_LOCAL_DEVICE",
|
|
36
|
+
"appium:app": "/absolute/path/to/game.apk"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
ravensight-playtest run --driver android_driver --driver-config ./ravensight-driver.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For iOS, use `ios_driver`, `platformName: iOS`, your XCUITest driver and a provisioned device or simulator. For desktop, use the capabilities required by your installed desktop driver. Unity/Unreal exports use `unity_driver`/`unreal_driver`; no engine source is uploaded by this configuration. Configuration files are local and should not contain provider credentials. Do not expose the automation server publicly.
|
|
46
|
+
|
|
47
|
+
The runner creates a W3C session, reads page source plus PNG screenshots, and executes bounded coordinate clicks, keys, text and single-pointer swipes. It releases input after each action and closes the session when done. One persona uses the device at a time. Native adapters currently capture screenshots, not video or engine logs. Browser navigation is unavailable on these adapters. Model-visible text is limited to 12,000 characters; responses are bounded to 8 MB and commands time out after 30 seconds.
|
|
48
|
+
|
|
49
|
+
## Custom bridge
|
|
50
|
+
|
|
51
|
+
Use this for text games or your own engine/hardware integration. `--driver-config` points to:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"command": "/absolute/path/to/bridge-executable",
|
|
56
|
+
"args": ["--game", "/absolute/path/to/build"],
|
|
57
|
+
"cwd": "/absolute/path/to/project"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The configured executable is trusted local code chosen by the developer. The model cannot change it. It runs without a shell. Standard output must contain newline-delimited JSON-RPC 2.0 responses. Put diagnostics on stderr. Respond with the request ID and `result`, or an `error`:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{"jsonrpc":"2.0","id":1,"method":"launch","params":{"protocolVersion":1}}
|
|
65
|
+
{"jsonrpc":"2.0","id":1,"result":{"ok":true}}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Required methods:
|
|
69
|
+
|
|
70
|
+
- `launch`: initialize/reset the game; return `{ "ok": true }`.
|
|
71
|
+
- `observe`: return `{ "text": "visible state" }`, optionally `screenshotBase64` containing PNG data.
|
|
72
|
+
- `act`: accept a `kind` of `key`, `type`, `click` or `wait`; return `{ "ok": true }` after execution. Apply your own game-specific legality checks. Never interpret model text as shell code.
|
|
73
|
+
- `screenshot`: return `{ "pngBase64": "..." }`, or a JSON-RPC error if unavailable. Text-only games can still produce text reports without screenshots.
|
|
74
|
+
|
|
75
|
+
Requests expire after 30 seconds. Oversized or malformed responses stop the bridge. The runner terminates its child when the session ends; the bridge must clean up any game processes it owns on termination. No custom protocol can establish compatibility with untested hardware by itself.
|
|
76
|
+
|
|
77
|
+
Protocol references: [W3C WebDriver](https://www.w3.org/TR/webdriver/), [Appium capabilities](https://appium.io/docs/en/3.2/guides/caps/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ravensight-playtest",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "AI personas play your game and file reports to Ravensight.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://ravensight.io/docs/#playtest",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"bin",
|
|
26
26
|
"src",
|
|
27
27
|
"addons",
|
|
28
|
+
"docs/ADAPTERS.md",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"!src/**/*.test.js",
|
package/src/cli.js
CHANGED
|
@@ -53,10 +53,11 @@ async function loadRunner(name, exportName) {
|
|
|
53
53
|
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
|
-
--driver <name> playwright_web or
|
|
56
|
+
--driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
|
|
57
|
+
--controller <name> standard or jev action selection (default standard)
|
|
57
58
|
--build-url <url> the web build to play
|
|
58
59
|
--godot-project <dir> the Godot project directory
|
|
59
|
-
--
|
|
60
|
+
--driver-config <file> local WebDriver or JSON-RPC bridge configuration
|
|
60
61
|
--max-actions <n> cap the actions one persona may take
|
|
61
62
|
--concurrency <n> personas at once, default 2
|
|
62
63
|
--upload-video upload session.webm as well as the reports
|
package/src/commands/fix.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { optionsFrom } from '../run/args.js';
|
|
5
5
|
import { getDeps } from '../run/deps.js';
|
|
@@ -443,7 +443,13 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
443
443
|
ledger,
|
|
444
444
|
log,
|
|
445
445
|
onProgress: progress => {
|
|
446
|
-
if (options.verbose)
|
|
446
|
+
if (options.verbose) {
|
|
447
|
+
log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
|
|
448
|
+
}
|
|
449
|
+
// A local transcript of the apply loop: which tools, on what, how much
|
|
450
|
+
// came back. Never uploaded; it is how a fix that wrote nothing gets
|
|
451
|
+
// explained afterwards, which the first production attempt could not be.
|
|
452
|
+
appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
|
|
447
453
|
},
|
|
448
454
|
shouldStop: () => interrupt.signalled || heartbeat.canceled
|
|
449
455
|
});
|
package/src/commands/run.js
CHANGED
|
@@ -10,6 +10,8 @@ import { createHeartbeat } from '../run/heartbeat.js';
|
|
|
10
10
|
import { createUsageLedger } from '../run/usage.js';
|
|
11
11
|
import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
|
|
12
12
|
import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
|
|
13
|
+
import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
14
|
+
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
13
15
|
import { runPersona } from '../run/personaLoop.js';
|
|
14
16
|
import * as stateFile from '../run/state.js';
|
|
15
17
|
import { emitRunEvents } from '../run/synthetic.js';
|
|
@@ -86,10 +88,11 @@ async function run(flags, injected, deps, log) {
|
|
|
86
88
|
if (setup.code !== undefined) return setup.code;
|
|
87
89
|
const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
|
|
88
90
|
|
|
91
|
+
if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
|
|
89
92
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
90
93
|
const target = resolveTarget(options, context.config, driverName);
|
|
91
94
|
if (!target) {
|
|
92
|
-
log('Point the run at a build: --build-url for a web build,
|
|
95
|
+
log('Point the run at a build: --build-url for a web build, --godot-project for Godot, or --driver-config for a native or text bridge.');
|
|
93
96
|
return EXIT.ENVIRONMENT;
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -97,8 +100,11 @@ async function run(flags, injected, deps, log) {
|
|
|
97
100
|
// `aggregate.*` steps are spendable from, and it is free (the price list has
|
|
98
101
|
// no unit for it), so asking costs nothing and not asking costs the judged
|
|
99
102
|
// dedup and success-criteria passes.
|
|
103
|
+
if (WEBDRIVER_NAMES.includes(driverName)) await readDriverConfig(target);
|
|
104
|
+
if (driverName === "cli_stdio") await readBridgeConfig(target);
|
|
100
105
|
const modules = ['persona_playtest', 'aggregate_report'];
|
|
101
106
|
const pack = await deps.packs.get({ api, gameId, modules });
|
|
107
|
+
if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
|
|
102
108
|
const personaSlugs = resolvePersonas(options, context.config, pack);
|
|
103
109
|
const personas = personaSlugs.map(slug => {
|
|
104
110
|
const found = (pack.personas || []).find(persona => persona.slug === slug);
|
|
@@ -164,7 +170,7 @@ async function run(flags, injected, deps, log) {
|
|
|
164
170
|
cli_version: cliVersion,
|
|
165
171
|
confirm_price_cents: estimate.estimate_cents,
|
|
166
172
|
brief_version: brief.version ?? null,
|
|
167
|
-
build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
|
|
173
|
+
build: { kind: driverName === 'playwright_web' ? 'url' : (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') ? 'driver_config' : 'godot_project', ref: target },
|
|
168
174
|
repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
|
|
169
175
|
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
170
176
|
pack_versions: { pack: pack.pack_version }
|
|
@@ -191,6 +197,7 @@ async function run(flags, injected, deps, log) {
|
|
|
191
197
|
priceCents: registered.price && registered.price.price_cents,
|
|
192
198
|
options: {
|
|
193
199
|
maxActions: options.maxActions ?? null,
|
|
200
|
+
controller: options.controller || 'standard',
|
|
194
201
|
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
195
202
|
uploadVideo: Boolean(options.uploadVideo),
|
|
196
203
|
uploadTranscript: Boolean(options.uploadTranscript)
|
|
@@ -349,7 +356,7 @@ export async function playJob({
|
|
|
349
356
|
|
|
350
357
|
const results = [];
|
|
351
358
|
const queue = personaRuns.filter(entry => !stateFile.isRunDone(journal.runs[entry.run_id]));
|
|
352
|
-
const concurrency = Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
|
|
359
|
+
const concurrency = WEBDRIVER_NAMES.includes(driverName) ? 1 : Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
|
|
353
360
|
|
|
354
361
|
/**
|
|
355
362
|
* Runs the process is in the middle of. A Ctrl-C has to move these to
|
|
@@ -516,6 +523,7 @@ export async function playPersonaRun({
|
|
|
516
523
|
try {
|
|
517
524
|
if (inFlight) inFlight.set(runId, { state: 'launching' });
|
|
518
525
|
outcome = await runPersona({
|
|
526
|
+
controller: options.controller || 'standard',
|
|
519
527
|
client,
|
|
520
528
|
api,
|
|
521
529
|
gameId,
|
|
@@ -985,6 +993,7 @@ export function resolveMaxActions(options, persona, pack) {
|
|
|
985
993
|
}
|
|
986
994
|
|
|
987
995
|
export function resolveTarget(options, config, driverName) {
|
|
996
|
+
if (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') return options.driverConfig || (config?.build?.kind === 'driver_config' ? config.build.ref : null);
|
|
988
997
|
const build = (config && config.build) || {};
|
|
989
998
|
if (driverName === 'playwright_web') return options.buildUrl || (build.kind === 'url' ? build.ref : null) || null;
|
|
990
999
|
if (driverName === 'godot_driver') return options.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
|
package/src/dashboard.js
CHANGED
|
@@ -36,24 +36,34 @@ export function dashboardUrl(apiUrl = resolveApiUrl()) {
|
|
|
36
36
|
return url.toString().replace(/\/+$/, '');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The dashboard's playtest routes carry no game in the path: the switcher
|
|
41
|
+
* holds the game, and a link names it in the query so the router can select
|
|
42
|
+
* it on arrival. `/games/<id>/playtest/...` was the first cut and answered
|
|
43
|
+
* "That page does not exist" for every link this CLI printed.
|
|
44
|
+
*/
|
|
45
|
+
function playtestPage(gameId, page, apiUrl) {
|
|
46
|
+
return `${dashboardUrl(apiUrl)}/playtest${page}?game=${encodeURIComponent(gameId)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** The dashboard page for one game's playtest surface. */
|
|
40
50
|
export function gameLink(gameId, apiUrl) {
|
|
41
|
-
return
|
|
51
|
+
return playtestPage(gameId, '', apiUrl);
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
/** The dashboard page for one job. Matches `links.dashboard` from POST /jobs. */
|
|
45
55
|
export function jobLink(gameId, jobId, apiUrl) {
|
|
46
|
-
return
|
|
56
|
+
return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}`, apiUrl);
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
/** The post job review form for one job. */
|
|
50
60
|
export function reviewLink(gameId, jobId, apiUrl) {
|
|
51
|
-
return
|
|
61
|
+
return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}/review`, apiUrl);
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** The brief form. */
|
|
55
65
|
export function briefLink(gameId, apiUrl) {
|
|
56
|
-
return
|
|
66
|
+
return playtestPage(gameId, '/brief', apiUrl);
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -63,7 +73,7 @@ export function briefLink(gameId, apiUrl) {
|
|
|
63
73
|
* server can keep true.
|
|
64
74
|
*/
|
|
65
75
|
export function trustLink(gameId, apiUrl) {
|
|
66
|
-
return
|
|
76
|
+
return playtestPage(gameId, '/what-leaves-your-machine', apiUrl);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ProxyError, STEPS } from './model.js';
|
|
2
|
+
export const CONTROLLERS = ['standard', 'jev'];
|
|
3
|
+
export const JEV_CONTROLLER_PROMPT = '\nRavensight Playtester action-selection mode: describe the currently visible screen briefly in text, including each proposed target and its screenshot coordinates, then propose one to three ALTERNATIVE next actions as tool calls. Only one will be executed after a separate selector chooses. These are alternatives, never a sequential plan. Use only observed controls. For a canvas game without accessible DOM controls, include a target label describing the visible control AND screenshot x/y coordinates; the driver uses those coordinates. Never invent selectors such as textbox or button names. When the brief identifies a field as a public player label, use Ravensight Playtester. Credentials or private access codes require human review. When the game is loading or transitioning, propose a bounded wait tool call. Do not place secrets in proposals. After their tool results, observe the resulting state before proposing again.';
|
|
4
|
+
export function describeAction(tool) {
|
|
5
|
+
const input = tool.input || {};
|
|
6
|
+
const descriptions = {
|
|
7
|
+
click: input.target ? `Click the visible ${input.target}${Number.isFinite(input.x) && Number.isFinite(input.y) ? ` at (${input.x}, ${input.y})` : ''}.` : `Click the observed screenshot position (${input.x}, ${input.y}).`,
|
|
8
|
+
type: `Type the prepared text into the focused field${input.submit ? ' and press Enter' : ''}.`,
|
|
9
|
+
key: `Press the ${input.key} key.`,
|
|
10
|
+
wait: `Wait ${input.ms} milliseconds for the screen to finish transitioning.`,
|
|
11
|
+
snapshot: 'Observe the current screen again.',
|
|
12
|
+
screenshot: 'Save a screenshot of the current screen for the report.',
|
|
13
|
+
navigate: `Navigate to the supplied build URL ${input.url}.`
|
|
14
|
+
};
|
|
15
|
+
return String(descriptions[tool.name] || `${tool.name}: ${JSON.stringify(input)}`).slice(0, 300);
|
|
16
|
+
}
|
|
17
|
+
export async function selectAction({ api, jobId, runId, persona, observation, toolUses, recentActions = [], intentNotes = [] }) {
|
|
18
|
+
const proposals = toolUses.slice(0, 8);
|
|
19
|
+
// Observation does not input anything into the game. Keep it in the existing
|
|
20
|
+
// bounded driver loop; Jev selects game inputs, not whether a frame may load.
|
|
21
|
+
if (proposals.length && proposals.every(tool => ['wait', 'snapshot', 'screenshot'].includes(tool.name))) {
|
|
22
|
+
return { status: 'selected', toolId: proposals[0].id, source: 'observation' };
|
|
23
|
+
}
|
|
24
|
+
const body = {
|
|
25
|
+
objective: ('Play the game according to the supplied persona, exploring visible controls and gathering evidence. Developer scope: ' + intentNotes.map(note => note.text || '').join(' ')).slice(0, 1000),
|
|
26
|
+
// The observer already applies the full persona. The selector needs its identity,
|
|
27
|
+
// current visible state and proposed inputs, not a second personality prompt.
|
|
28
|
+
persona: String(persona.name || persona.slug || 'Playtester').slice(0, 200),
|
|
29
|
+
observation: String(observation).slice(0, 6000),
|
|
30
|
+
actions: proposals.map((tool, index) => ({ id: `action_${index}`, description: describeAction(tool) })),
|
|
31
|
+
recentActions: recentActions.slice(-10).map(value => String(value).slice(0, 100))
|
|
32
|
+
};
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = (await api.request('POST', '/api/v1/playtest/model/v1/decisions', {
|
|
36
|
+
body, idempotent: false,
|
|
37
|
+
headers: { 'X-Playtest-Job': jobId, 'X-Playtest-Run': runId, 'X-Playtest-Step': STEPS.personaAct }
|
|
38
|
+
})).body;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.status === 402 || error.status === 409) throw new ProxyError({ status: error.status, code: error.code || 'decision_refused', message: 'The action selector stopped because this run is no longer spendable.' });
|
|
41
|
+
return { status: 'review', reason: 'decision_unavailable' };
|
|
42
|
+
}
|
|
43
|
+
if (result.status !== 'suggested') return result;
|
|
44
|
+
const index = body.actions.findIndex(action => action.id === result.actionId);
|
|
45
|
+
if (index < 0 || !Number.isFinite(result.confidence) || result.confidence < 0.75) return { status: 'review', reason: result.actionId === 'stop' ? 'selector_stopped' : 'uncertain_action' };
|
|
46
|
+
return { status: 'selected', toolId: proposals[index].id, confidence: result.confidence, budget: result.budget };
|
|
47
|
+
}
|
package/src/run/args.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
const VALUED = new Map([
|
|
15
15
|
['--personas', 'personas'],
|
|
16
16
|
['--driver', 'driver'],
|
|
17
|
+
['--driver-config', 'driverConfig'],
|
|
18
|
+
['--controller', 'controller'],
|
|
17
19
|
['--build-url', 'buildUrl'],
|
|
18
20
|
['--build-dir', 'buildDir'],
|
|
19
21
|
['--godot-project', 'godotProject'],
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The driver interface. One shape
|
|
4
|
+
* The driver interface. One shared shape across implementations (spec 03's
|
|
5
5
|
* "Driver interface (all drivers)"): `playwright_web` here in ./web.js,
|
|
6
|
-
* `godot_driver` in ./godot.js, and
|
|
6
|
+
* `godot_driver` in ./godot.js, plus native and custom bridge adapters.
|
|
7
7
|
*
|
|
8
8
|
* A driver is the ONLY thing in this CLI that touches the game. The model
|
|
9
9
|
* never gets a shell, a file write outside the run directory, or a network
|
|
@@ -138,7 +138,10 @@ export function assertDriver(driver, name) {
|
|
|
138
138
|
export function observationHash(observation) {
|
|
139
139
|
const text = typeof observation === 'string' ? observation : String((observation && observation.text) || '');
|
|
140
140
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
141
|
-
|
|
141
|
+
const hash = createHash('sha256').update(normalized, 'utf8');
|
|
142
|
+
// Canvas games keep the same DOM text while their visible state changes.
|
|
143
|
+
if (observation && observation.screenshotBase64) hash.update(observation.screenshotBase64, 'base64');
|
|
144
|
+
return hash.digest('hex').slice(0, 32);
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
/**
|
package/src/run/drivers/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { assertDriver, DriverError } from './driver.js';
|
|
2
2
|
import { createWebDriver } from './web.js';
|
|
3
|
+
import { createWebDriverAdapter, WEBDRIVER_NAMES } from './webdriver.js';
|
|
4
|
+
import { createStdioDriver } from './stdio.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The driver registry. `playwright_web` is here; `godot_driver` lives in
|
|
6
8
|
* ./godot.js and is imported lazily so this package still loads on a
|
|
7
|
-
* machine with no Godot and no addon, and
|
|
9
|
+
* machine with no Godot and no addon, and native/custom adapters use local developer-owned connections.
|
|
8
10
|
*
|
|
9
11
|
* The names are the server's `PlaytestRun.driver` enum and the report
|
|
10
12
|
* schema's `driver` enum, not ours to invent.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio']);
|
|
15
|
+
export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio', ...WEBDRIVER_NAMES]);
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* @param {string} name
|
|
@@ -19,6 +21,7 @@ export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_std
|
|
|
19
21
|
* @returns {Promise<Object>} a driver satisfying ./driver.js
|
|
20
22
|
*/
|
|
21
23
|
export async function createDriver(name, options = {}) {
|
|
24
|
+
if (WEBDRIVER_NAMES.includes(name)) return assertDriver(createWebDriverAdapter({...options,name}),name);
|
|
22
25
|
const load = options.importer || (specifier => import(specifier));
|
|
23
26
|
switch (name) {
|
|
24
27
|
case 'playwright_web':
|
|
@@ -40,10 +43,7 @@ export async function createDriver(name, options = {}) {
|
|
|
40
43
|
return assertDriver(await factory(options), name);
|
|
41
44
|
}
|
|
42
45
|
case 'cli_stdio':
|
|
43
|
-
|
|
44
|
-
'The cli_stdio driver is not in this release. In v1 a text build is played through its web front end with --driver playwright_web.',
|
|
45
|
-
{ fatal: true }
|
|
46
|
-
);
|
|
46
|
+
return assertDriver(createStdioDriver(options), name);
|
|
47
47
|
default:
|
|
48
48
|
throw new DriverError(`unknown driver "${name}"; one of: ${DRIVERS.join(', ')}`, { fatal: true });
|
|
49
49
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { DriverError, observationHash, screenshotName, truncateObservation } from './driver.js';
|
|
5
|
+
|
|
6
|
+
// A developer-owned bridge implements newline-delimited JSON-RPC. The model
|
|
7
|
+
// receives game controls only, never command arguments or process access.
|
|
8
|
+
export async function readBridgeConfig(file) {
|
|
9
|
+
if ((await fs.stat(file)).size > 64000) throw new DriverError('Bridge configuration exceeds 64 KB.');
|
|
10
|
+
const config = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
11
|
+
if (typeof config.command !== 'string' || !path.isAbsolute(config.command) ||
|
|
12
|
+
(config.args !== undefined && (!Array.isArray(config.args) || config.args.some(v => typeof v !== 'string'))) ||
|
|
13
|
+
(config.cwd !== undefined && !path.isAbsolute(config.cwd))) {
|
|
14
|
+
throw new DriverError('Bridge config needs an absolute command, string arguments and an optional absolute cwd.');
|
|
15
|
+
}
|
|
16
|
+
await fs.access(config.command, fs.constants.X_OK);
|
|
17
|
+
return config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
|
|
21
|
+
let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
const fail = message => {
|
|
24
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
|
|
25
|
+
pending.clear();
|
|
26
|
+
};
|
|
27
|
+
function request(method, params = {}) {
|
|
28
|
+
if (!child || child.exitCode !== null || child.killed) return Promise.reject(new DriverError('Game bridge is not running.', { fatal: true }));
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const id = ++nextId;
|
|
31
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new DriverError('Game bridge timed out.', { fatal: true })); child.kill(); }, timeoutMs);
|
|
32
|
+
pending.set(id, { resolve, reject, timer });
|
|
33
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => {
|
|
34
|
+
if (error) { clearTimeout(timer); pending.delete(id); reject(new DriverError('Game bridge input failed.')); }
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function observe() {
|
|
39
|
+
const value = await request('observe');
|
|
40
|
+
if (!value || typeof value.text !== 'string') throw new DriverError('Bridge observation needs text.');
|
|
41
|
+
const observation = { step: ++step, text: truncateObservation(value.text), elapsedMs: 0 };
|
|
42
|
+
if (typeof value.screenshotBase64 === 'string' && value.screenshotBase64.startsWith('iVBORw0KGgo')) {
|
|
43
|
+
observation.screenshotBase64 = value.screenshotBase64;
|
|
44
|
+
observation.screenshotMediaType = 'image/png';
|
|
45
|
+
}
|
|
46
|
+
lastHash = observationHash(observation);
|
|
47
|
+
return observation;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name: 'cli_stdio', supportedActions: ['type', 'key', 'click', 'wait'],
|
|
51
|
+
async launch({ target, runDir }) {
|
|
52
|
+
const config = await readBridgeConfig(target);
|
|
53
|
+
runDirectory = runDir;
|
|
54
|
+
child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
55
|
+
child.stdout.setEncoding('utf8');
|
|
56
|
+
child.stdout.on('data', chunk => {
|
|
57
|
+
buffer += chunk;
|
|
58
|
+
if (Buffer.byteLength(buffer) > 8 * 1024 * 1024) { fail('Game bridge response exceeded 8 MB.'); child.kill(); return; }
|
|
59
|
+
let end;
|
|
60
|
+
while ((end = buffer.indexOf('\n')) !== -1) {
|
|
61
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
62
|
+
let value;
|
|
63
|
+
try { value = JSON.parse(line); } catch { fail('Game bridge returned invalid JSON.'); child.kill(); return; }
|
|
64
|
+
const entry = pending.get(value?.id);
|
|
65
|
+
if (!entry) continue;
|
|
66
|
+
pending.delete(value.id); clearTimeout(entry.timer);
|
|
67
|
+
if (value.jsonrpc !== '2.0' || value.error) entry.reject(new DriverError('Game bridge command failed.'));
|
|
68
|
+
else entry.resolve(value.result);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Drain diagnostics without copying arbitrary game output into reports.
|
|
72
|
+
child.stderr.resume();
|
|
73
|
+
child.stdin.on('error', () => fail('Game bridge input failed.'));
|
|
74
|
+
child.on('error', () => fail('Game bridge could not start.'));
|
|
75
|
+
child.on('exit', () => fail('Game bridge exited.'));
|
|
76
|
+
await fs.mkdir(path.join(runDir, 'screenshots'), { recursive: true });
|
|
77
|
+
await request('launch', { protocolVersion: 1 });
|
|
78
|
+
},
|
|
79
|
+
observe,
|
|
80
|
+
async act(action) {
|
|
81
|
+
if (!['type', 'key', 'click', 'wait'].includes(action.kind)) return { ok: false, changed: false, error: 'Unsupported bridge action.' };
|
|
82
|
+
const before = lastHash;
|
|
83
|
+
const safe = action.kind === 'type' ? { kind: 'type', text: String(action.text || '').slice(0, 2000), submit: Boolean(action.submit) }
|
|
84
|
+
: action.kind === 'key' ? { kind: 'key', key: String(action.key || '').slice(0, 64) }
|
|
85
|
+
: action.kind === 'wait' ? { kind: 'wait', ms: Math.min(5000, Math.max(0, Number(action.ms) || 0)) }
|
|
86
|
+
: { kind: 'click', target: typeof action.target === 'string' ? action.target.slice(0, 500) : action.target };
|
|
87
|
+
const answer = await request('act', safe);
|
|
88
|
+
if (answer?.ok !== true) return { ok: false, changed: false, error: 'Game bridge refused this action.' };
|
|
89
|
+
return { ok: true, changed: observationHash(await observe()) !== before };
|
|
90
|
+
},
|
|
91
|
+
async screenshot(reason) {
|
|
92
|
+
const image = await request('screenshot');
|
|
93
|
+
if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
|
|
94
|
+
const relativePath = path.join('screenshots', screenshotName(++shots, reason));
|
|
95
|
+
const file = path.join(runDirectory, relativePath);
|
|
96
|
+
await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
|
|
97
|
+
return { path: file, relativePath };
|
|
98
|
+
},
|
|
99
|
+
async consoleErrors() { return []; },
|
|
100
|
+
async stop() {
|
|
101
|
+
if (child && child.exitCode === null && !child.killed) {
|
|
102
|
+
child.kill('SIGTERM');
|
|
103
|
+
const process = child;
|
|
104
|
+
const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
|
|
105
|
+
timer.unref();
|
|
106
|
+
}
|
|
107
|
+
fail('Game bridge stopped.');
|
|
108
|
+
return { videoPath: null };
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/run/drivers/web.js
CHANGED
|
@@ -293,7 +293,13 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
293
293
|
try {
|
|
294
294
|
// The same truncation observe() applies, so the two hashes are
|
|
295
295
|
// comparable. Without it a long screen is always "changed".
|
|
296
|
-
|
|
296
|
+
const text = truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS);
|
|
297
|
+
const after = { text };
|
|
298
|
+
if (text.trim().length < THIN_OBSERVATION_CHARS) {
|
|
299
|
+
const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
300
|
+
after.screenshotBase64 = frame.toString('base64');
|
|
301
|
+
}
|
|
302
|
+
changed = observationHash(after) !== before;
|
|
297
303
|
} catch {
|
|
298
304
|
changed = true;
|
|
299
305
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Native desktop and mobile adapters use the W3C session/actions protocol.
|
|
2
|
+
// The developer supplies a local Appium/WebDriver server and a reviewed config;
|
|
3
|
+
// game observations cannot choose servers, capabilities, executables or scripts.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { DriverError, observationHash, screenshotName, truncateObservation } from './driver.js';
|
|
7
|
+
|
|
8
|
+
export const WEBDRIVER_NAMES = ['desktop_driver', 'unity_driver', 'unreal_driver', 'android_driver', 'ios_driver'];
|
|
9
|
+
const KEYS = { Enter:'\uE007', Escape:'\uE00C', Space:' ', Tab:'\uE004', Backspace:'\uE003', ArrowUp:'\uE013', ArrowDown:'\uE015', ArrowLeft:'\uE012', ArrowRight:'\uE014' };
|
|
10
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
export async function readDriverConfig(file) {
|
|
12
|
+
const stat=await fs.stat(file); if(stat.size>64000) throw new DriverError('Driver configuration exceeds 64 KB.',{fatal:true});
|
|
13
|
+
const config=JSON.parse(await fs.readFile(file,'utf8'));
|
|
14
|
+
const url=new URL(config.endpoint);
|
|
15
|
+
if(!['http:','https:'].includes(url.protocol)||!['localhost','127.0.0.1','[::1]'].includes(url.hostname)||url.username||url.password||url.search||url.hash) throw new DriverError('The device server must use a loopback URL without credentials.',{fatal:true});
|
|
16
|
+
if(!config.capabilities||Array.isArray(config.capabilities)||typeof config.capabilities!=='object'||typeof config.capabilities.platformName!=='string') throw new DriverError('Supply W3C capabilities with platformName.',{fatal:true});
|
|
17
|
+
return {...config,endpoint:url.href.replace(/\/$/,'')};
|
|
18
|
+
}
|
|
19
|
+
export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=globalThis.fetch,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}) {
|
|
20
|
+
let config,session,runDirectory,step=0,shots=0,lastHash=null;
|
|
21
|
+
async function request(method,route,body) {
|
|
22
|
+
const response=await fetchImpl(config.endpoint+route,{method,redirect:'error',signal:AbortSignal.timeout(30000),headers:{'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{})});
|
|
23
|
+
if(response.status===204) return null;
|
|
24
|
+
if(!response.body) throw new DriverError('Device response was empty.');
|
|
25
|
+
const reader=response.body.getReader();const chunks=[];let size=0;
|
|
26
|
+
try {for(;;){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>MAX_BYTES){await reader.cancel();throw new DriverError('Device response exceeded the limit.');}chunks.push(Buffer.from(part.value));}}finally{reader.releaseLock();}
|
|
27
|
+
const data=JSON.parse(Buffer.concat(chunks).toString());
|
|
28
|
+
if(!response.ok||data.value?.error)throw new DriverError('Device command failed: '+String(data.value?.error||response.status));
|
|
29
|
+
return data.value;
|
|
30
|
+
}
|
|
31
|
+
const route=tail=>{if(!session)throw new DriverError('No device session is active.',{fatal:true});return '/session/'+encodeURIComponent(session)+tail;};
|
|
32
|
+
async function frame(){const data=await request('GET',route('/screenshot'));if(typeof data!=='string'||!data.startsWith('iVBORw0KGgo'))throw new DriverError('Device screenshot is not a PNG.');return data;}
|
|
33
|
+
async function observe(){const [source,image]=await Promise.all([request('GET',route('/source')),frame()]);const observation={step:++step,text:truncateObservation(typeof source==='string'?source:JSON.stringify(source)),screenshotBase64:image,screenshotMediaType:'image/png',elapsedMs:0};lastHash=observationHash(observation);return observation;}
|
|
34
|
+
async function actions(type,values,pointerType='mouse'){await request('POST',route('/actions'),{actions:[{type,id:'ravensight',...(type==='pointer'?{parameters:{pointerType}}:{}),actions:values}]});}
|
|
35
|
+
const driver={name,supportedActions:['click','type','key','wait','swipe'],
|
|
36
|
+
async launch({target,runDir}){
|
|
37
|
+
config=await readDriverConfig(target);runDirectory=runDir;
|
|
38
|
+
const platform=config.capabilities.platformName.toLowerCase();
|
|
39
|
+
if(name==='android_driver'&&platform!=='android'||name==='ios_driver'&&platform!=='ios')throw new DriverError('The device platform does not match the selected adapter.',{fatal:true});
|
|
40
|
+
const answer=await request('POST','/session',{capabilities:{alwaysMatch:config.capabilities,firstMatch:[{}]}});
|
|
41
|
+
if(!answer||typeof answer.sessionId!=='string')throw new DriverError('The device server did not create a session.',{fatal:true});
|
|
42
|
+
session=answer.sessionId;await fs.mkdir(path.join(runDir,'screenshots'),{recursive:true});
|
|
43
|
+
},observe,
|
|
44
|
+
async act(action){
|
|
45
|
+
const before=lastHash;const touch=['android_driver','ios_driver'].includes(name)?'touch':'mouse';
|
|
46
|
+
const point=value=>{if(!value||!Number.isFinite(value.x)||!Number.isFinite(value.y)||value.x<0||value.y<0||value.x>32768||value.y>32768)throw new DriverError('Supply bounded screenshot coordinates.');return {x:Math.round(value.x),y:Math.round(value.y)};};
|
|
47
|
+
try {
|
|
48
|
+
if(action.kind==='wait')await sleep(Math.min(5000,Math.max(0,Number(action.ms)||0)));
|
|
49
|
+
else if(action.kind==='click'){
|
|
50
|
+
const p=point(action.target);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...p},{type:'pointerDown',button:0},{type:'pointerUp',button:0}],touch);
|
|
51
|
+
}else if(action.kind==='swipe'){
|
|
52
|
+
const from=point(action.from),to=point(action.to);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...from},{type:'pointerDown',button:0},{type:'pointerMove',duration:Math.min(2000,Math.max(100,Number(action.durationMs)||500)),origin:'viewport',...to},{type:'pointerUp',button:0}],touch);
|
|
53
|
+
}else if(action.kind==='key'||action.kind==='type'){
|
|
54
|
+
const chars=action.kind==='type'?Array.from(String(action.text||'').slice(0,2000)):[KEYS[action.key]||action.key];
|
|
55
|
+
if(chars.some(key=>typeof key!=='string'||Array.from(key).length!==1))throw new DriverError('Unsupported device key.');
|
|
56
|
+
if(action.submit)chars.push(KEYS.Enter);
|
|
57
|
+
await actions('key',chars.flatMap(value=>[{type:'keyDown',value},{type:'pause',duration:Math.min(2000,Math.max(0,Number(action.durationMs)||0))},{type:'keyUp',value}]));
|
|
58
|
+
}else throw new DriverError('This device adapter does not support that action.');
|
|
59
|
+
return {ok:true,changed:observationHash(await observe())!==before};
|
|
60
|
+
}catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
|
|
61
|
+
finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
|
|
62
|
+
},
|
|
63
|
+
async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
|
|
64
|
+
async consoleErrors(){return [];},
|
|
65
|
+
async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
|
|
66
|
+
};return driver;
|
|
67
|
+
}
|
package/src/run/fixLoop.js
CHANGED
|
@@ -37,6 +37,81 @@ export const SUMMARY_MAX_TOKENS = 4096;
|
|
|
37
37
|
*/
|
|
38
38
|
export const MAX_FIX_ACTIONS = 60;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* How many tool-answering turns the plan step may take before it has to
|
|
42
|
+
* answer. A real codebase takes several reads to locate a behavior; one
|
|
43
|
+
* repair turn (the first cut) produced empty plans on every real repository.
|
|
44
|
+
*/
|
|
45
|
+
export const PLAN_MAX_TURNS = 6;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every this-many apply turns without a write, the loop tells the model how
|
|
49
|
+
* many actions are left and asks it to write or say what blocks it. A model
|
|
50
|
+
* that is lost otherwise reads until the budget warning ends the run.
|
|
51
|
+
*/
|
|
52
|
+
export const NUDGE_EVERY = 8;
|
|
53
|
+
|
|
54
|
+
/** One breakpoint, frozen so no caller can hand a mutated one to the next turn. */
|
|
55
|
+
const CACHE_BREAKPOINT = Object.freeze({ type: 'ephemeral' });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Move a cache breakpoint onto the newest turn of a conversation.
|
|
59
|
+
*
|
|
60
|
+
* `fixSystem` already marks the end of the system prompt, but a breakpoint
|
|
61
|
+
* there only ever caches the skill and the repository facts. The tokens on
|
|
62
|
+
* this path are in `messages`: a `read_file` result is a whole source file,
|
|
63
|
+
* and the apply loop accumulates dozens of them over up to sixty turns, every
|
|
64
|
+
* one of which re-sends the whole conversation. Without a breakpoint in
|
|
65
|
+
* `messages` that conversation is processed at full price on every turn, which
|
|
66
|
+
* is quadratic in the number of turns and is where `fix.apply`'s bill comes
|
|
67
|
+
* from.
|
|
68
|
+
*
|
|
69
|
+
* The prefix written on turn N is exactly the prefix read on turn N+1, because
|
|
70
|
+
* these two loops only ever APPEND to `messages`. Nothing rewrites an earlier
|
|
71
|
+
* turn, so the bytes ahead of the marker are identical from one call to the
|
|
72
|
+
* next. (`personaLoop` deliberately does not do this: `prepareMessages`
|
|
73
|
+
* rewrites older observations into stubs as a run goes on, so its prefix is not
|
|
74
|
+
* stable and a breakpoint in its messages would pay the write premium instead
|
|
75
|
+
* of earning a read.)
|
|
76
|
+
*
|
|
77
|
+
* Applied to a COPY, leaving the stored conversation unmarked, for two
|
|
78
|
+
* reasons. A request may carry at most four breakpoints, so a marker left
|
|
79
|
+
* behind on each turn would be refused by the fifth. And the bytes this call
|
|
80
|
+
* sends for an earlier turn have to be the bytes the last call sent, which a
|
|
81
|
+
* marker written into the history would change. A marker that moves is not an
|
|
82
|
+
* invalidator; one that accumulates is.
|
|
83
|
+
*
|
|
84
|
+
* No breakpoint can leak anything across runs or across customers. A cache
|
|
85
|
+
* read requires the caller to present the identical prefix bytes, so the only
|
|
86
|
+
* thing an entry can ever serve back is a prompt whoever asked for it already
|
|
87
|
+
* held. The proxy passes `messages` through by reference, so nothing about
|
|
88
|
+
* this needs the server's field allowlist widened.
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<Object>} messages the conversation so far, unmarked
|
|
91
|
+
* @returns {Array<Object>} the conversation to send, with one moving marker
|
|
92
|
+
*/
|
|
93
|
+
export function withRollingCacheBreakpoint(messages) {
|
|
94
|
+
const last = messages.at(-1);
|
|
95
|
+
if (!last || !Array.isArray(last.content) || last.content.length === 0) return messages;
|
|
96
|
+
const block = last.content.at(-1);
|
|
97
|
+
if (!block || typeof block !== 'object') return messages;
|
|
98
|
+
const content = [...last.content];
|
|
99
|
+
content[content.length - 1] = { ...block, cache_control: CACHE_BREAKPOINT };
|
|
100
|
+
return [...messages.slice(0, -1), { ...last, content }];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The text of a tool result, whichever of the two content shapes it uses. */
|
|
104
|
+
function resultText(result) {
|
|
105
|
+
if (!result) return '';
|
|
106
|
+
if (typeof result.content === 'string') return result.content;
|
|
107
|
+
if (Array.isArray(result.content)) return result.content.map(block => (block && block.text) || '').join('');
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resultLength(result) {
|
|
112
|
+
return resultText(result).length;
|
|
113
|
+
}
|
|
114
|
+
|
|
40
115
|
/** A plan that says nothing, which is what a malformed answer becomes. */
|
|
41
116
|
export const EMPTY_PLAN = Object.freeze({ hypothesis: '', files_to_change: [], out_of_scope: [] });
|
|
42
117
|
|
|
@@ -176,14 +251,18 @@ export async function planFix({
|
|
|
176
251
|
let repaired = false;
|
|
177
252
|
let plan = null;
|
|
178
253
|
|
|
179
|
-
|
|
254
|
+
// Reading turns are answered and counted; a turn with no tool calls is
|
|
255
|
+
// the answer, and one repair is allowed when that answer is not JSON. At
|
|
256
|
+
// the ceiling the model is told to answer now, and if it still reaches for
|
|
257
|
+
// a tool the plan stays empty: planning is not where the budget belongs.
|
|
258
|
+
while (turns < PLAN_MAX_TURNS + 1 && !plan) {
|
|
180
259
|
const answer = await callStepWithRetry({
|
|
181
260
|
client,
|
|
182
261
|
jobId,
|
|
183
262
|
runId,
|
|
184
263
|
step: STEPS.fixPlan,
|
|
185
264
|
system,
|
|
186
|
-
messages,
|
|
265
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
187
266
|
tools: repo ? repo.tools : undefined,
|
|
188
267
|
maxTokens: PLAN_MAX_TOKENS
|
|
189
268
|
});
|
|
@@ -196,9 +275,13 @@ export async function planFix({
|
|
|
196
275
|
plan = normalisePlan(parsed);
|
|
197
276
|
break;
|
|
198
277
|
}
|
|
199
|
-
|
|
278
|
+
const reading = answer.toolUses.length > 0 && Boolean(repo);
|
|
279
|
+
if (!reading) {
|
|
280
|
+
if (repaired) break;
|
|
281
|
+
repaired = true;
|
|
282
|
+
}
|
|
283
|
+
if (turns > PLAN_MAX_TURNS) break;
|
|
200
284
|
|
|
201
|
-
repaired = true;
|
|
202
285
|
messages.push({
|
|
203
286
|
role: 'assistant',
|
|
204
287
|
content: [
|
|
@@ -212,16 +295,15 @@ export async function planFix({
|
|
|
212
295
|
const executed = await repo.execute(toolUse);
|
|
213
296
|
results.push(executed.result);
|
|
214
297
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
});
|
|
298
|
+
const nudge = turns >= PLAN_MAX_TURNS
|
|
299
|
+
? 'No more reading. Answer now with the JSON object only, nothing before or after it.'
|
|
300
|
+
: reading
|
|
301
|
+
? `${PLAN_MAX_TURNS - turns} more reading turn(s) before you have to answer with the JSON object.`
|
|
302
|
+
: 'That was not the plan object. Answer with the JSON object only, nothing before or after it.';
|
|
303
|
+
messages.push({ role: 'user', content: [...results, { type: 'text', text: nudge }] });
|
|
222
304
|
}
|
|
223
305
|
|
|
224
|
-
if (!plan) log(
|
|
306
|
+
if (!plan) log(`The plan did not come back as JSON within ${PLAN_MAX_TURNS} reading turns and one repair, so the fix proceeds without one.`);
|
|
225
307
|
return { plan: plan || { ...EMPTY_PLAN }, turns, repaired, messages };
|
|
226
308
|
}
|
|
227
309
|
|
|
@@ -275,7 +357,7 @@ export async function applyFix({
|
|
|
275
357
|
content: [{
|
|
276
358
|
type: 'text',
|
|
277
359
|
text: [
|
|
278
|
-
'Make the change now. Read before you
|
|
360
|
+
'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
|
|
279
361
|
'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.',
|
|
280
362
|
'',
|
|
281
363
|
`<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
|
|
@@ -286,6 +368,10 @@ export async function applyFix({
|
|
|
286
368
|
|
|
287
369
|
let turns = 0;
|
|
288
370
|
let stop = 'max_actions';
|
|
371
|
+
// Every call the loop has already answered, so a lost model that asks the
|
|
372
|
+
// same question twice is told so instead of paying for the same answer.
|
|
373
|
+
const answered = new Map();
|
|
374
|
+
let turnsSinceWrite = 0;
|
|
289
375
|
while (turns < maxActions) {
|
|
290
376
|
if (shouldStop()) {
|
|
291
377
|
stop = 'interrupted';
|
|
@@ -300,7 +386,7 @@ export async function applyFix({
|
|
|
300
386
|
runId,
|
|
301
387
|
step: STEPS.fixApply,
|
|
302
388
|
system,
|
|
303
|
-
messages,
|
|
389
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
304
390
|
tools: repo.tools,
|
|
305
391
|
maxTokens: APPLY_MAX_TOKENS
|
|
306
392
|
});
|
|
@@ -328,10 +414,32 @@ export async function applyFix({
|
|
|
328
414
|
]
|
|
329
415
|
});
|
|
330
416
|
const results = [];
|
|
417
|
+
let wroteThisTurn = false;
|
|
331
418
|
for (const toolUse of answer.toolUses) {
|
|
419
|
+
const key = `${toolUse.name}:${JSON.stringify(toolUse.input || {})}`;
|
|
420
|
+
const repeat = answered.get(key);
|
|
332
421
|
const executed = await repo.execute(toolUse);
|
|
333
|
-
|
|
334
|
-
|
|
422
|
+
const result = repeat === undefined
|
|
423
|
+
? executed.result
|
|
424
|
+
: { ...executed.result, content: [{ type: 'text', text: `(identical to a call you already made on turn ${repeat}; the answer has not changed)\n${resultText(executed.result)}` }] };
|
|
425
|
+
answered.set(key, repeat === undefined ? turns : repeat);
|
|
426
|
+
results.push(result);
|
|
427
|
+
if ((toolUse.name === 'write_file' || toolUse.name === 'edit_file') && !executed.result.is_error) wroteThisTurn = true;
|
|
428
|
+
onProgress({
|
|
429
|
+
turn: turns,
|
|
430
|
+
tool: toolUse.name,
|
|
431
|
+
target: toolUse.input && (toolUse.input.path || toolUse.input.pattern),
|
|
432
|
+
resultChars: resultLength(executed.result),
|
|
433
|
+
isError: Boolean(executed.result.is_error),
|
|
434
|
+
repeat: repeat !== undefined
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
turnsSinceWrite = wroteThisTurn ? 0 : turnsSinceWrite + 1;
|
|
438
|
+
if (turnsSinceWrite > 0 && turnsSinceWrite % NUDGE_EVERY === 0 && turns < maxActions) {
|
|
439
|
+
results.push({
|
|
440
|
+
type: 'text',
|
|
441
|
+
text: `You have used ${turns} of ${maxActions} actions and written nothing. Either make the change now with edit_file (or write_file for a new file), or answer in one line, with no tool call, why it cannot be made.`
|
|
442
|
+
});
|
|
335
443
|
}
|
|
336
444
|
messages.push({ role: 'user', content: results });
|
|
337
445
|
|
|
@@ -427,4 +535,4 @@ export function clamp(value, limit) {
|
|
|
427
535
|
return text.length <= limit ? text : text.slice(0, limit);
|
|
428
536
|
}
|
|
429
537
|
|
|
430
|
-
export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan };
|
|
538
|
+
export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
|
package/src/run/personaLoop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
1
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
@@ -6,7 +7,7 @@ import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
|
6
7
|
import { RUN_FILES } from './paths.js';
|
|
7
8
|
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
8
9
|
import { assertNoSecrets } from './secretScan.js';
|
|
9
|
-
import { ACTION_TOOLS, PLAY_TOOLS, REPORT_TOOLS, executeTool } from './tools.js';
|
|
10
|
+
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
10
11
|
import { createTranscript } from './transcript.js';
|
|
11
12
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
12
13
|
|
|
@@ -114,6 +115,7 @@ export async function runPersona(args) {
|
|
|
114
115
|
job_id: jobId,
|
|
115
116
|
persona: persona.slug,
|
|
116
117
|
driver: driverName,
|
|
118
|
+
controller: args.controller || 'standard',
|
|
117
119
|
target,
|
|
118
120
|
prompt_version: promptVersionOf(skill),
|
|
119
121
|
brief_id: brief ? brief.brief_id || null : null,
|
|
@@ -248,6 +250,8 @@ export async function runPersona(args) {
|
|
|
248
250
|
* @returns {Promise<Object>}
|
|
249
251
|
*/
|
|
250
252
|
async function playLoop({
|
|
253
|
+
api,
|
|
254
|
+
controller = 'standard',
|
|
251
255
|
client,
|
|
252
256
|
jobId,
|
|
253
257
|
runId,
|
|
@@ -270,6 +274,25 @@ async function playLoop({
|
|
|
270
274
|
log = () => {}
|
|
271
275
|
}) {
|
|
272
276
|
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
277
|
+
if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
278
|
+
const availableTools = driver.supportedActions
|
|
279
|
+
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
280
|
+
: PLAY_TOOLS;
|
|
281
|
+
const playTools = controller === 'jev' ? availableTools.map(tool => ({
|
|
282
|
+
...tool,
|
|
283
|
+
input_schema: {
|
|
284
|
+
...tool.input_schema,
|
|
285
|
+
required: [...(tool.input_schema.required || []), 'visible_state', ...(tool.name === 'click' ? ['target'] : [])],
|
|
286
|
+
properties: {
|
|
287
|
+
...tool.input_schema.properties,
|
|
288
|
+
visible_state: { type: 'string', description: 'Describe what is visibly on the current screenshot, including this action target and its coordinates. This observation is required even when the assistant text is empty. Do not claim unobserved state.' },
|
|
289
|
+
...(tool.name === 'click' ? { target: { type: 'string', description: 'Label of the visible control. For canvas also supply x and y; the driver uses those coordinates.' } } : {})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})) : availableTools;
|
|
293
|
+
const recentActions = [];
|
|
294
|
+
let latestObservation = '';
|
|
295
|
+
let visualObservation = false;
|
|
273
296
|
const messages = [];
|
|
274
297
|
let actions = 0;
|
|
275
298
|
let turns = 0;
|
|
@@ -283,6 +306,8 @@ async function playLoop({
|
|
|
283
306
|
const maxTurns = maxActions * 3 + 20;
|
|
284
307
|
|
|
285
308
|
const first = await driver.observe();
|
|
309
|
+
latestObservation = first.text;
|
|
310
|
+
visualObservation = Boolean(first.screenshotBase64);
|
|
286
311
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
287
312
|
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
288
313
|
messages.push({
|
|
@@ -317,7 +342,7 @@ async function playLoop({
|
|
|
317
342
|
step: STEPS.personaAct,
|
|
318
343
|
system,
|
|
319
344
|
messages,
|
|
320
|
-
tools:
|
|
345
|
+
tools: playTools,
|
|
321
346
|
maxTokens: ACT_MAX_TOKENS,
|
|
322
347
|
escalate: shouldEscalate(persona),
|
|
323
348
|
imageBudget,
|
|
@@ -370,9 +395,23 @@ async function playLoop({
|
|
|
370
395
|
break;
|
|
371
396
|
}
|
|
372
397
|
|
|
398
|
+
let selection = null;
|
|
399
|
+
if (controller === 'jev') {
|
|
400
|
+
try {
|
|
401
|
+
selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
|
|
404
|
+
if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
408
|
+
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
409
|
+
if (selection.budget === 'warn') { budgetWarned = true; wrapUpLeft = Math.min(wrapUpLeft, WRAP_UP_TURNS); }
|
|
410
|
+
}
|
|
373
411
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
374
412
|
const results = [];
|
|
375
413
|
for (const toolUse of answer.toolUses) {
|
|
414
|
+
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
376
415
|
const executed = await executeTool({
|
|
377
416
|
driver,
|
|
378
417
|
toolUse,
|
|
@@ -402,6 +441,10 @@ async function playLoop({
|
|
|
402
441
|
actions
|
|
403
442
|
});
|
|
404
443
|
if (executed.observation) {
|
|
444
|
+
latestObservation = executed.observation.text;
|
|
445
|
+
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
446
|
+
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
447
|
+
if (recentActions.length > 10) recentActions.shift();
|
|
405
448
|
transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
|
|
406
449
|
// Only an input to the game can tell us anything about being stuck. A
|
|
407
450
|
// `snapshot` or a `screenshot` never changes the screen by design, so
|
package/src/run/repoTools.js
CHANGED
|
@@ -68,7 +68,13 @@ export const MAX_READ_BYTES = 120000;
|
|
|
68
68
|
export const MAX_GLOB_RESULTS = 400;
|
|
69
69
|
|
|
70
70
|
/** How many matching lines a grep answers with. */
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Sixty, not two hundred: at three hundred characters a line the old cap
|
|
73
|
+
* let one grep put 60 KB into the context, and forty greps in one apply loop
|
|
74
|
+
* ate the whole model budget without a write. A capped answer says so, so
|
|
75
|
+
* the model narrows rather than repeats.
|
|
76
|
+
*/
|
|
77
|
+
export const MAX_GREP_RESULTS = 60;
|
|
72
78
|
|
|
73
79
|
/** The tool definitions for `game_profile.triage`. */
|
|
74
80
|
export const PROFILE_TOOLS = Object.freeze([
|
|
@@ -126,10 +132,34 @@ export const MAX_WRITE_BYTES = 200000;
|
|
|
126
132
|
/** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
|
|
127
133
|
export const MAX_WRITES_PER_RUN = 20;
|
|
128
134
|
|
|
129
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* The two writing tools, defined here so nothing else can invent a third.
|
|
137
|
+
*
|
|
138
|
+
* `edit_file` exists because whole-file writes do not survive contact with a
|
|
139
|
+
* real codebase: the first production fix attempt read an 84 KB C++ file and
|
|
140
|
+
* then could not send it back inside the step's output ceiling, so its one
|
|
141
|
+
* write arrived as malformed input and the attempt ended with nothing on the
|
|
142
|
+
* branch. An exact-text replacement is a few hundred bytes for the same edit.
|
|
143
|
+
*/
|
|
144
|
+
export const EDIT_FILE_TOOL = Object.freeze({
|
|
145
|
+
name: 'edit_file',
|
|
146
|
+
description: 'Replace one exact passage of a text file in the repository with new text. Paths are relative to the repository root. old_text must appear exactly once in the file (or set replace_all for every occurrence); copy it verbatim from read_file, whitespace included. This is the tool for changing a file that already exists.',
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
required: ['path', 'old_text', 'new_text'],
|
|
151
|
+
properties: {
|
|
152
|
+
path: { type: 'string' },
|
|
153
|
+
old_text: { type: 'string' },
|
|
154
|
+
new_text: { type: 'string' },
|
|
155
|
+
replace_all: { type: 'boolean' }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
130
160
|
export const WRITE_FILE_TOOL = Object.freeze({
|
|
131
161
|
name: 'write_file',
|
|
132
|
-
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root.
|
|
162
|
+
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Use it for a new file or a small one; for a change to an existing file use edit_file, because the content you send here is the whole file.',
|
|
133
163
|
input_schema: {
|
|
134
164
|
type: 'object',
|
|
135
165
|
additionalProperties: false,
|
|
@@ -141,8 +171,8 @@ export const WRITE_FILE_TOOL = Object.freeze({
|
|
|
141
171
|
}
|
|
142
172
|
});
|
|
143
173
|
|
|
144
|
-
/** The tool definitions for `fix.apply`: the read-only three, plus the
|
|
145
|
-
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, WRITE_FILE_TOOL]);
|
|
174
|
+
/** The tool definitions for `fix.apply`: the read-only three, plus the two writes. */
|
|
175
|
+
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, EDIT_FILE_TOOL, WRITE_FILE_TOOL]);
|
|
146
176
|
|
|
147
177
|
/**
|
|
148
178
|
* Compile a glob into a regular expression. Supports `**`, `*` and `?`,
|
|
@@ -388,6 +418,14 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
388
418
|
const left = MAX_WRITES_PER_RUN - written.size;
|
|
389
419
|
return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
|
|
390
420
|
}
|
|
421
|
+
if (toolUse.name === 'edit_file' && canWrite) {
|
|
422
|
+
const planned = await planEdit(root, input, written);
|
|
423
|
+
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
424
|
+
await doWrite(planned.absolute, planned.content);
|
|
425
|
+
written.add(planned.relative);
|
|
426
|
+
const left = MAX_WRITES_PER_RUN - written.size;
|
|
427
|
+
return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
|
|
428
|
+
}
|
|
391
429
|
if (toolUse.name === 'glob') {
|
|
392
430
|
const matcher = globToRegExp(input.pattern);
|
|
393
431
|
const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
|
|
@@ -495,6 +533,54 @@ export async function planWrite(root, input, written = new Set()) {
|
|
|
495
533
|
* @param {string} absolute
|
|
496
534
|
* @param {string} content
|
|
497
535
|
*/
|
|
536
|
+
/**
|
|
537
|
+
* Everything `planWrite` checks about the path, then the replacement itself:
|
|
538
|
+
* the file has to exist and be text, `old_text` has to be found exactly once
|
|
539
|
+
* unless `replace_all`, and the result has to fit the same byte ceiling a
|
|
540
|
+
* whole write does. Refusals name the count found, because "not found" and
|
|
541
|
+
* "found three times" call for different next moves.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} root
|
|
544
|
+
* @param {Object} input
|
|
545
|
+
* @param {Set<string>} written
|
|
546
|
+
* @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
|
|
547
|
+
*/
|
|
548
|
+
export async function planEdit(root, input, written = new Set()) {
|
|
549
|
+
const oldText = input && input.old_text;
|
|
550
|
+
const newText = input && input.new_text;
|
|
551
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
552
|
+
return { refusal: 'Refused: edit_file needs old_text, the exact passage to replace, copied verbatim from read_file.' };
|
|
553
|
+
}
|
|
554
|
+
if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
|
|
555
|
+
// The path rules are the write rules; the placeholder content only has to be
|
|
556
|
+
// a string so the shared checks run.
|
|
557
|
+
const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
|
|
558
|
+
if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
|
|
559
|
+
|
|
560
|
+
let current;
|
|
561
|
+
try {
|
|
562
|
+
current = await readFile(planned.absolute, 'utf8');
|
|
563
|
+
} catch {
|
|
564
|
+
return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
|
|
565
|
+
}
|
|
566
|
+
if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
|
|
567
|
+
|
|
568
|
+
const occurrences = current.split(oldText).length - 1;
|
|
569
|
+
if (occurrences === 0) {
|
|
570
|
+
return { refusal: `Refused: old_text was not found in ${planned.relative}. Read the file again and copy the passage exactly, whitespace included.` };
|
|
571
|
+
}
|
|
572
|
+
if (occurrences > 1 && !(input && input.replace_all)) {
|
|
573
|
+
return { refusal: `Refused: old_text appears ${occurrences} times in ${planned.relative}. Include more surrounding lines so it is unique, or set replace_all.` };
|
|
574
|
+
}
|
|
575
|
+
const content = input && input.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, () => newText);
|
|
576
|
+
if (content.includes(NUL)) return { refusal: 'Refused: new_text holds a NUL byte, so that is not text.' };
|
|
577
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
578
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
579
|
+
return { refusal: `Refused: ${planned.relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}.` };
|
|
580
|
+
}
|
|
581
|
+
return { relative: planned.relative, absolute: planned.absolute, content, bytes, replaced: input && input.replace_all ? occurrences : 1 };
|
|
582
|
+
}
|
|
583
|
+
|
|
498
584
|
async function doWrite(absolute, content) {
|
|
499
585
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
500
586
|
const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -532,7 +618,11 @@ async function doGrep(root, all, input) {
|
|
|
532
618
|
if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
|
|
533
619
|
}
|
|
534
620
|
}
|
|
535
|
-
|
|
621
|
+
if (hits.length === 0) return '(no matches)';
|
|
622
|
+
const capped = hits.length >= MAX_GREP_RESULTS;
|
|
623
|
+
return capped
|
|
624
|
+
? `${hits.join('\n')}\n(stopped at ${MAX_GREP_RESULTS} matches; narrow the pattern or add a glob)`
|
|
625
|
+
: hits.join('\n');
|
|
536
626
|
}
|
|
537
627
|
|
|
538
628
|
function ok(id, text, isError = false) {
|
|
@@ -544,6 +634,8 @@ export default {
|
|
|
544
634
|
PROFILE_TOOLS,
|
|
545
635
|
READ_ONLY_TOOLS,
|
|
546
636
|
WRITE_TOOLS,
|
|
637
|
+
EDIT_FILE_TOOL,
|
|
638
|
+
planEdit,
|
|
547
639
|
globToRegExp,
|
|
548
640
|
listFiles,
|
|
549
641
|
planWrite,
|
package/src/run/resume.js
CHANGED
|
@@ -153,6 +153,7 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
153
153
|
|
|
154
154
|
const resumeOptions = {
|
|
155
155
|
...options,
|
|
156
|
+
controller: journal.options?.controller || options.controller || 'standard',
|
|
156
157
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
157
158
|
concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
|
|
158
159
|
uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
|
package/src/run/tools.js
CHANGED
|
@@ -29,7 +29,16 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
|
|
|
29
29
|
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
30
30
|
|
|
31
31
|
/** Tools that spend an action. */
|
|
32
|
-
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
|
|
32
|
+
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
|
|
33
|
+
|
|
34
|
+
export const SWIPE_TOOL = {
|
|
35
|
+
name: 'swipe',
|
|
36
|
+
description: 'Drag from one screenshot coordinate to another. Spends one action.',
|
|
37
|
+
input_schema: {
|
|
38
|
+
type: 'object', additionalProperties: false, required: ['x', 'y', 'toX', 'toY'],
|
|
39
|
+
properties: Object.fromEntries(['x', 'y', 'toX', 'toY', 'durationMs'].map(key => [key, { type: 'number', minimum: 0, maximum: key === 'durationMs' ? 2000 : 32768 }]))
|
|
40
|
+
}
|
|
41
|
+
};
|
|
33
42
|
|
|
34
43
|
const thought = {
|
|
35
44
|
type: 'string',
|
|
@@ -185,6 +194,8 @@ export function actionFor(toolUse) {
|
|
|
185
194
|
return { kind: 'navigate', url: String(input.url || '') };
|
|
186
195
|
case 'wait':
|
|
187
196
|
return { kind: 'wait', ms: Number(input.ms) || 0 };
|
|
197
|
+
case 'swipe':
|
|
198
|
+
return { kind: 'swipe', from: { x: input.x, y: input.y }, to: { x: input.toX, y: input.toY }, durationMs: input.durationMs };
|
|
188
199
|
default:
|
|
189
200
|
return null;
|
|
190
201
|
}
|
package/src/states.js
CHANGED