ravensight-playtest 0.2.3 → 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 +26 -0
- package/docs/ADAPTERS.md +77 -0
- package/package.json +2 -1
- package/src/cli.js +3 -2
- package/src/commands/run.js +12 -3
- 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 +52 -3
- package/src/run/personaLoop.js +45 -2
- package/src/run/resume.js +1 -0
- package/src/run/tools.js +12 -1
- package/src/states.js +4 -1
package/README.md
CHANGED
|
@@ -465,3 +465,29 @@ import { createClient, packs, uploadRunDir } from 'ravensight-playtest';
|
|
|
465
465
|
## License
|
|
466
466
|
|
|
467
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/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;
|
|
@@ -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
|
@@ -51,6 +51,55 @@ export const PLAN_MAX_TURNS = 6;
|
|
|
51
51
|
*/
|
|
52
52
|
export const NUDGE_EVERY = 8;
|
|
53
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
|
+
|
|
54
103
|
/** The text of a tool result, whichever of the two content shapes it uses. */
|
|
55
104
|
function resultText(result) {
|
|
56
105
|
if (!result) return '';
|
|
@@ -213,7 +262,7 @@ export async function planFix({
|
|
|
213
262
|
runId,
|
|
214
263
|
step: STEPS.fixPlan,
|
|
215
264
|
system,
|
|
216
|
-
messages,
|
|
265
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
217
266
|
tools: repo ? repo.tools : undefined,
|
|
218
267
|
maxTokens: PLAN_MAX_TOKENS
|
|
219
268
|
});
|
|
@@ -337,7 +386,7 @@ export async function applyFix({
|
|
|
337
386
|
runId,
|
|
338
387
|
step: STEPS.fixApply,
|
|
339
388
|
system,
|
|
340
|
-
messages,
|
|
389
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
341
390
|
tools: repo.tools,
|
|
342
391
|
maxTokens: APPLY_MAX_TOKENS
|
|
343
392
|
});
|
|
@@ -486,4 +535,4 @@ export function clamp(value, limit) {
|
|
|
486
535
|
return text.length <= limit ? text : text.slice(0, limit);
|
|
487
536
|
}
|
|
488
537
|
|
|
489
|
-
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/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