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 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).
@@ -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.0",
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 godot_driver
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
- --cli <command> the command that starts a text build
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
@@ -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) log(` ${progress.tool} ${progress.target || ''}`);
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
  });
@@ -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, or --godot-project for a Godot project.');
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 `${dashboardUrl(apiUrl)}/games/${encodeURIComponent(gameId)}/playtest`;
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 `${gameLink(gameId, apiUrl)}/jobs/${encodeURIComponent(jobId)}`;
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 `${jobLink(gameId, jobId, apiUrl)}/review`;
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 `${gameLink(gameId, apiUrl)}/brief`;
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 `${gameLink(gameId, apiUrl)}/what-leaves-your-machine`;
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, three implementations (spec 03's
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 `cli_stdio` later.
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
- return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 32);
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
  /**
@@ -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 `cli_stdio` is not in v1.
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
- throw new DriverError(
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
+ }
@@ -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
- changed = observationHash(truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS)) !== before;
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
+ }
@@ -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
- for (let attempt = 1; attempt <= 2 && !plan; attempt += 1) {
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
- if (attempt === 2) break;
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
- messages.push({
216
- role: 'user',
217
- content: [
218
- ...results,
219
- { type: 'text', text: 'That was not the plan object. Answer with the JSON object only, nothing before or after it.' }
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('The plan did not come back as JSON after one repair, so the fix proceeds without one.');
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 write: `write_file` replaces the whole file, so send the file back with your edit in it.',
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
- results.push(executed.result);
334
- onProgress({ turn: turns, tool: toolUse.name, target: toolUse.input && (toolUse.input.path || toolUse.input.pattern) });
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 };
@@ -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: PLAY_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
@@ -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
- export const MAX_GREP_RESULTS = 200;
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
- /** The one write tool, defined here so nothing else can invent a second one. */
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. Read the file first: there is no patch tool, so the content you send is the file.',
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 write. */
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
- return hits.length > 0 ? hits.join('\n') : '(no matches)';
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
@@ -26,7 +26,10 @@ export const DRIVERS = Object.freeze([
26
26
  'godot_driver',
27
27
  'cli_stdio',
28
28
  'unity_driver',
29
- 'unreal_driver'
29
+ 'unreal_driver',
30
+ 'desktop_driver',
31
+ 'android_driver',
32
+ 'ios_driver'
30
33
  ]);
31
34
 
32
35
  export const JOB_STATES = Object.freeze([