ravensight-playtest 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +249 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +92 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +165 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
package/src/run/repoTools.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import
|
|
4
|
+
import vm from 'node:vm';
|
|
5
|
+
import { isMediaPath } from '../assets/inventory.js';
|
|
6
|
+
import { allIgnoredFiles, hooksDirectory, ignoredPaths } from './git.js';
|
|
7
|
+
import { MAX_SCAN_BYTES, scanForSecrets } from './secretScan.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* The read-only source tools for `game_profile`: `read_file`, `glob` and
|
|
@@ -49,6 +52,20 @@ export const SKIP_DIRS = Object.freeze([
|
|
|
49
52
|
*/
|
|
50
53
|
export const NEVER_TOUCHED = Object.freeze(['.git', '.ravensight']);
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* What a fix reports with: this CLI writes them beside its journals once the fix
|
|
57
|
+
* is done, and they are never part of the change. A model once wrote them into
|
|
58
|
+
* the repository root as well, where they were counted as the fix's own edits
|
|
59
|
+
* ("+77 -1" for a three line change). Only at the root, and only when the project
|
|
60
|
+
* does not already have one: a project's own report.md is the project's.
|
|
61
|
+
*/
|
|
62
|
+
export const RUN_OUTPUT_NAMES = Object.freeze(['changeset.json', 'report.md']);
|
|
63
|
+
|
|
64
|
+
/** @param {string} relativePath */
|
|
65
|
+
export function isRunOutputName(relativePath) {
|
|
66
|
+
return RUN_OUTPUT_NAMES.includes(String(relativePath).toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/** `SKIP_DIRS`, lowercased once, because the comparison is case insensitive. */
|
|
53
70
|
const SKIP_DIRS_LOWER = Object.freeze(SKIP_DIRS.map(name => name.toLowerCase()));
|
|
54
71
|
|
|
@@ -57,10 +74,77 @@ export const SECRET_FILE_PATTERNS = Object.freeze([
|
|
|
57
74
|
/(^|\/)\.env($|\.)/i,
|
|
58
75
|
/(^|\/)\.npmrc$/i,
|
|
59
76
|
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/i,
|
|
60
|
-
/(^|\/)credentials
|
|
61
|
-
/\.(pem|key|p12|pfx|keystore|jks)$/i
|
|
77
|
+
/(^|\/)credentials(\.[a-z]+)?$/i,
|
|
78
|
+
/\.(pem|key|p8|ppk|p12|pfx|keystore|jks)$/i,
|
|
79
|
+
/(^|\/)[._]netrc$/i,
|
|
80
|
+
/(^|\/)\.(git-credentials|pgpass|pypirc|htpasswd|dockercfg)$/i,
|
|
81
|
+
/(^|\/)secrets?\.(json|ya?ml|toml|cfg|ini|xml|properties)$/i,
|
|
82
|
+
/(^|\/)(google-services\.json|googleservice-info\.plist)$/i,
|
|
83
|
+
/(^|\/)service[-_]?account[^/]*\.json$/i,
|
|
84
|
+
/(^|\/)\.(aws|ssh|gnupg|kube|docker)\//i,
|
|
85
|
+
// Godot keeps signing passwords here: in export_presets.cfg up to 4.2, and in
|
|
86
|
+
// export_credentials.cfg after it.
|
|
87
|
+
/(^|\/)export_(presets|credentials)\.cfg$/i,
|
|
88
|
+
// A game's Ravensight key file, written by `create` and ignored by git.
|
|
89
|
+
/(^|\/)ravensight_key(\.[a-z]+)?\.cfg$/i
|
|
62
90
|
]);
|
|
63
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Files that are not the game. What is in them runs on a developer's or a CI
|
|
94
|
+
* machine, or changes what git shows, so a write here is either code execution
|
|
95
|
+
* (`git commit` runs a hook, and `--open-pr` commits) or a change that hides other
|
|
96
|
+
* changes (a `.gitignore` that ignores the file just written). None of it is what
|
|
97
|
+
* a playtest finding asks for, so it is never written, whatever the model read.
|
|
98
|
+
*/
|
|
99
|
+
export const NEVER_WRITTEN_PATTERNS = Object.freeze([
|
|
100
|
+
/(^|\/)\.(husky|githooks)(\/|$)/i,
|
|
101
|
+
/(^|\/)\.git(ignore|attributes|modules|config)$/i,
|
|
102
|
+
/(^|\/)\.mailmap$/i,
|
|
103
|
+
/(^|\/)\.envrc$/i,
|
|
104
|
+
/^\.github\/(workflows|actions)\//i,
|
|
105
|
+
/^\.gitlab-ci\.ya?ml$/i,
|
|
106
|
+
/^\.circleci\//i,
|
|
107
|
+
/^\.pre-commit-config\.ya?ml$/i,
|
|
108
|
+
/(^|\/)\.?lefthook(-local)?\.ya?ml$/i,
|
|
109
|
+
/(^|\/)\.vscode\//i
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A file name that means something else somewhere else: control characters and
|
|
114
|
+
* invisible or direction-changing ones (a terminal, a code review), a trailing dot
|
|
115
|
+
* or space and a colon or short name (`.git.`, `.git::$INDEX_ALLOCATION` and `git~1`
|
|
116
|
+
* are `.git` on Windows), and a backslash (a separator on Windows, a filename here).
|
|
117
|
+
*/
|
|
118
|
+
/* eslint-disable-next-line no-control-regex */
|
|
119
|
+
const UNSAFE_NAME_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff:\\]/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} relative
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function isUnsafeName(relative) {
|
|
126
|
+
if (UNSAFE_NAME_CHARS.test(relative)) return true;
|
|
127
|
+
return relative.split('/').some(segment => /[. ]$/.test(segment) || /~\d+$/.test(segment));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The directory under the root that is its own repository, if the path goes
|
|
132
|
+
* through one. The outer diff shows a submodule as one line with no counts and a
|
|
133
|
+
* nested repository's new files not at all, so a write there is one nobody reviews.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} root
|
|
136
|
+
* @param {string} relative
|
|
137
|
+
* @returns {Promise<string|null>}
|
|
138
|
+
*/
|
|
139
|
+
async function nestedRepositoryIn(root, relative) {
|
|
140
|
+
const parts = relative.split('/').slice(0, -1);
|
|
141
|
+
for (let i = 1; i <= parts.length; i += 1) {
|
|
142
|
+
const directory = parts.slice(0, i).join('/');
|
|
143
|
+
if (await lstat(path.join(root, directory, '.git')).then(() => true, () => false)) return directory;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
64
148
|
/** How much of one file a single read returns. */
|
|
65
149
|
export const MAX_READ_BYTES = 120000;
|
|
66
150
|
|
|
@@ -381,14 +465,18 @@ export async function listFiles(root, { limit = 20000 } = {}) {
|
|
|
381
465
|
* @param {string} args.root the repository root
|
|
382
466
|
* @returns {{tools: Array, execute: (toolUse: Object) => Promise<{result: Object}>}}
|
|
383
467
|
*/
|
|
384
|
-
export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
468
|
+
export function createRepoTools({ root, tools = PROFILE_TOOLS, maxWrites = MAX_WRITES_PER_RUN }) {
|
|
385
469
|
let cachedFiles = null;
|
|
386
470
|
const canWrite = tools.some(tool => tool.name === 'write_file');
|
|
387
471
|
/** The distinct paths this run has written, which is what the cap counts. */
|
|
388
472
|
const written = new Set();
|
|
389
473
|
|
|
390
474
|
async function files() {
|
|
391
|
-
if (!cachedFiles)
|
|
475
|
+
if (!cachedFiles) {
|
|
476
|
+
const all = await listFiles(root);
|
|
477
|
+
const ignored = await allIgnoredFiles(root);
|
|
478
|
+
cachedFiles = all.filter(file => !ignored.has(file));
|
|
479
|
+
}
|
|
392
480
|
return cachedFiles;
|
|
393
481
|
}
|
|
394
482
|
|
|
@@ -411,25 +499,23 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
411
499
|
// A refusal comes back as an `is_error` result naming the rule,
|
|
412
500
|
// never as a thrown error: the model has to be able to read why and
|
|
413
501
|
// try a legal write instead, and a thrown error would end the run.
|
|
414
|
-
const planned = await planWrite(root, input, written);
|
|
502
|
+
const planned = await planWrite(root, input, written, maxWrites);
|
|
415
503
|
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
416
504
|
await doWrite(planned.absolute, input.content);
|
|
417
505
|
written.add(planned.relative);
|
|
418
|
-
const left =
|
|
506
|
+
const left = maxWrites - written.size;
|
|
419
507
|
return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
|
|
420
508
|
}
|
|
421
509
|
if (toolUse.name === 'edit_file' && canWrite) {
|
|
422
|
-
const planned = await planEdit(root, input, written);
|
|
510
|
+
const planned = await planEdit(root, input, written, maxWrites);
|
|
423
511
|
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
424
512
|
await doWrite(planned.absolute, planned.content);
|
|
425
513
|
written.add(planned.relative);
|
|
426
|
-
const left =
|
|
514
|
+
const left = maxWrites - written.size;
|
|
427
515
|
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
516
|
}
|
|
429
517
|
if (toolUse.name === 'glob') {
|
|
430
|
-
|
|
431
|
-
const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
|
|
432
|
-
return { result: ok(toolUse.id, matched.length > 0 ? matched.join('\n') : '(no matches)') };
|
|
518
|
+
return { result: ok(toolUse.id, doGlob(await files(), input.pattern)) };
|
|
433
519
|
}
|
|
434
520
|
if (toolUse.name === 'grep') return { result: ok(toolUse.id, await doGrep(root, await files(), input)) };
|
|
435
521
|
return { result: ok(toolUse.id, `There is no tool called ${toolUse.name}.`, true) };
|
|
@@ -440,6 +526,12 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
440
526
|
};
|
|
441
527
|
}
|
|
442
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Art, audio and anything else that is not text never reach the model: the bytes say
|
|
531
|
+
* nothing it can use, and they would spend the run's budget and leave this machine.
|
|
532
|
+
*/
|
|
533
|
+
const NOT_TEXT = relative => `Refused: ${relative} is an art, audio or other binary file, and its content is never read or sent.`;
|
|
534
|
+
|
|
443
535
|
async function doRead(root, input) {
|
|
444
536
|
const asked = String(input.path || '');
|
|
445
537
|
if (isSecretPath(asked)) {
|
|
@@ -449,13 +541,28 @@ async function doRead(root, input) {
|
|
|
449
541
|
// symlinked directory would otherwise let a read leave the repository, and
|
|
450
542
|
// `/etc/passwd` read through `shared/` is still a read of `/etc/passwd`.
|
|
451
543
|
const { absolute, relative } = await resolveInsideReal(root, asked);
|
|
544
|
+
// Again on the resolved path: `.env/` and `.env/.` are spellings of `.env` that
|
|
545
|
+
// the filesystem accepts and the patterns above do not.
|
|
546
|
+
if (isSecretPath(relative)) {
|
|
547
|
+
return `Refused: ${relative} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
|
|
548
|
+
}
|
|
452
549
|
const skipped = skippedDirIn(relative);
|
|
453
550
|
if (skipped) {
|
|
454
551
|
return `Refused: ${relative} is under ${skipped}, which is not part of the game's source.`;
|
|
455
552
|
}
|
|
553
|
+
// What a developer told git to leave alone is local to this machine, and it is
|
|
554
|
+
// where a key with no recognisable shape lives.
|
|
555
|
+
if (relative && (await ignoredPaths(root, [relative])).has(relative)) {
|
|
556
|
+
return `Refused: ${relative} is gitignored, so it is local to this machine and is never read.`;
|
|
557
|
+
}
|
|
456
558
|
const info = await stat(absolute);
|
|
457
559
|
if (!info.isFile()) throw new Error(`${relative} is not a file`);
|
|
560
|
+
if (isMediaPath(relative)) return NOT_TEXT(relative);
|
|
561
|
+
// Refused before it is loaded: a raw asset dump would otherwise be read into
|
|
562
|
+
// memory whole, only to be refused by the scan below.
|
|
563
|
+
if (info.size > MAX_SCAN_BYTES) return `Refused: ${relative} is too large (${info.size} bytes) to read here.`;
|
|
458
564
|
const text = await readFile(absolute, 'utf8');
|
|
565
|
+
if (text.includes(NUL)) return NOT_TEXT(relative);
|
|
459
566
|
const secrets = scanForSecrets(text);
|
|
460
567
|
if (secrets.length > 0) {
|
|
461
568
|
// The content is withheld rather than redacted: a partial redaction of
|
|
@@ -485,7 +592,7 @@ async function doRead(root, input) {
|
|
|
485
592
|
* @param {Set<string>} written the distinct paths written so far
|
|
486
593
|
* @returns {Promise<{relative: string, absolute: string, bytes: number}|{refusal: string}>}
|
|
487
594
|
*/
|
|
488
|
-
export async function planWrite(root, input, written = new Set()) {
|
|
595
|
+
export async function planWrite(root, input, written = new Set(), maxWrites = MAX_WRITES_PER_RUN) {
|
|
489
596
|
const asked = String((input && input.path) || '').trim();
|
|
490
597
|
if (!asked) return { refusal: 'Refused: write_file needs a path relative to the repository root.' };
|
|
491
598
|
|
|
@@ -507,6 +614,31 @@ export async function planWrite(root, input, written = new Set()) {
|
|
|
507
614
|
if (skipped) {
|
|
508
615
|
return { refusal: `Refused: ${relative} is under ${skipped}, which is generated, vendored or this tool's own bookkeeping, and is never written.` };
|
|
509
616
|
}
|
|
617
|
+
if (isUnsafeName(relative)) {
|
|
618
|
+
return { refusal: 'Refused: that file name holds characters that are unsafe in a terminal, in git or on another filesystem. Use a plain name.' };
|
|
619
|
+
}
|
|
620
|
+
if (NEVER_WRITTEN_PATTERNS.some(pattern => pattern.test(relative))) {
|
|
621
|
+
return { refusal: `Refused: ${relative} controls git or runs on a developer's or CI machine, and is never written. Change only the game.` };
|
|
622
|
+
}
|
|
623
|
+
const hooks = await hooksDirectory(root);
|
|
624
|
+
if (hooks && (absolute === hooks || absolute.startsWith(hooks + path.sep))) {
|
|
625
|
+
return { refusal: `Refused: ${relative} is in this repository's git hooks directory, and is never written.` };
|
|
626
|
+
}
|
|
627
|
+
const nested = await nestedRepositoryIn(root, relative);
|
|
628
|
+
if (nested) {
|
|
629
|
+
return { refusal: `Refused: ${nested} is its own git repository, so a change inside it would not show in this branch's diff. Change only this repository's files.` };
|
|
630
|
+
}
|
|
631
|
+
if (isMediaPath(relative)) {
|
|
632
|
+
return { refusal: `Refused: ${relative} is an art, audio or other binary file, and only text files are written.` };
|
|
633
|
+
}
|
|
634
|
+
// Git would never show this write: the changeset would call it no change, the
|
|
635
|
+
// unit would be refunded, and the developer's file would stay edited.
|
|
636
|
+
if ((await ignoredPaths(root, [relative])).has(relative)) {
|
|
637
|
+
return { refusal: `Refused: ${relative} is gitignored, so a change to it could never be reviewed. Change a tracked file, or a new file git will see.` };
|
|
638
|
+
}
|
|
639
|
+
if (isRunOutputName(relative) && !(await lstat(absolute).catch(() => null))) {
|
|
640
|
+
return { refusal: `Refused: ${relative} is a report this CLI writes itself once you stop, and it is never part of the change. Change only the project's own files.` };
|
|
641
|
+
}
|
|
510
642
|
|
|
511
643
|
const content = input && input.content;
|
|
512
644
|
if (typeof content !== 'string') {
|
|
@@ -519,8 +651,13 @@ export async function planWrite(root, input, written = new Set()) {
|
|
|
519
651
|
if (bytes > MAX_WRITE_BYTES) {
|
|
520
652
|
return { refusal: `Refused: ${relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}. Change less of it, or change a smaller file.` };
|
|
521
653
|
}
|
|
522
|
-
|
|
523
|
-
|
|
654
|
+
const existing = await lstat(absolute).catch(() => null);
|
|
655
|
+
if (existing && !existing.isFile()) return { refusal: `Refused: ${relative} is not a regular file.` };
|
|
656
|
+
if (existing && !isPlainText(await readFile(absolute).catch(() => Buffer.alloc(0)))) {
|
|
657
|
+
return { refusal: `Refused: ${relative} is not UTF-8 text, and replacing it would destroy it.` };
|
|
658
|
+
}
|
|
659
|
+
if (!written.has(relative) && written.size >= maxWrites) {
|
|
660
|
+
return { refusal: `Refused: this run has already written ${written.size} files and may write at most ${maxWrites}. Finish with the files you have already changed.` };
|
|
524
661
|
}
|
|
525
662
|
return { relative, absolute, bytes };
|
|
526
663
|
}
|
|
@@ -545,7 +682,7 @@ export async function planWrite(root, input, written = new Set()) {
|
|
|
545
682
|
* @param {Set<string>} written
|
|
546
683
|
* @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
|
|
547
684
|
*/
|
|
548
|
-
export async function planEdit(root, input, written = new Set()) {
|
|
685
|
+
export async function planEdit(root, input, written = new Set(), maxWrites = MAX_WRITES_PER_RUN) {
|
|
549
686
|
const oldText = input && input.old_text;
|
|
550
687
|
const newText = input && input.new_text;
|
|
551
688
|
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
@@ -554,16 +691,19 @@ export async function planEdit(root, input, written = new Set()) {
|
|
|
554
691
|
if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
|
|
555
692
|
// The path rules are the write rules; the placeholder content only has to be
|
|
556
693
|
// a string so the shared checks run.
|
|
557
|
-
const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
|
|
694
|
+
const planned = await planWrite(root, { path: input && input.path, content: '' }, written, maxWrites);
|
|
558
695
|
if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
|
|
559
696
|
|
|
560
697
|
let current;
|
|
561
698
|
try {
|
|
562
|
-
|
|
699
|
+
const raw = await readFile(planned.absolute);
|
|
700
|
+
// Decoding other bytes as UTF-8 and writing them back would corrupt every
|
|
701
|
+
// character outside the passage being replaced.
|
|
702
|
+
if (!isPlainText(raw)) return { refusal: `Refused: ${planned.relative} is not UTF-8 text, so it cannot be edited here without damaging it.` };
|
|
703
|
+
current = raw.toString('utf8');
|
|
563
704
|
} catch {
|
|
564
705
|
return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
|
|
565
706
|
}
|
|
566
|
-
if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
|
|
567
707
|
|
|
568
708
|
const occurrences = current.split(oldText).length - 1;
|
|
569
709
|
if (occurrences === 0) {
|
|
@@ -584,8 +724,11 @@ export async function planEdit(root, input, written = new Set()) {
|
|
|
584
724
|
async function doWrite(absolute, content) {
|
|
585
725
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
586
726
|
const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
|
|
587
|
-
|
|
727
|
+
// A rename takes the new file's permissions, and git records the executable bit.
|
|
728
|
+
const before = await stat(absolute).catch(() => null);
|
|
588
729
|
try {
|
|
730
|
+
await writeFile(tmp, content, 'utf8');
|
|
731
|
+
if (before) await chmod(tmp, before.mode & 0o777);
|
|
589
732
|
await rename(tmp, absolute);
|
|
590
733
|
} catch (error) {
|
|
591
734
|
// A failed rename would otherwise leave the half-named temp file in the
|
|
@@ -595,29 +738,72 @@ async function doWrite(absolute, content) {
|
|
|
595
738
|
}
|
|
596
739
|
}
|
|
597
740
|
|
|
741
|
+
/**
|
|
742
|
+
* A model's pattern is untrusted, and a catastrophic one spins inside one
|
|
743
|
+
* synchronous call: no timer fires, no signal handler runs, Ctrl-C does nothing and
|
|
744
|
+
* the job's heartbeat stops. So every pattern runs under a time limit, in a script
|
|
745
|
+
* V8 can interrupt, and a limit reached is an answer the model can act on.
|
|
746
|
+
*/
|
|
747
|
+
export const MAX_PATTERN_MS = 3000;
|
|
748
|
+
|
|
749
|
+
const SCAN = new vm.Script('for (; next < items.length && found.length < limit; next += 1) { if (pattern.test(items[next])) found.push(next); }');
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {number} [budgetMs]
|
|
753
|
+
* @returns {(pattern: RegExp, items: Array<string>, limit: number) => Array<number>|null} matching
|
|
754
|
+
* indexes; `stopped` says the budget ran out, and what was found so far stands
|
|
755
|
+
*/
|
|
756
|
+
function boundedMatcher(budgetMs = MAX_PATTERN_MS) {
|
|
757
|
+
const context = vm.createContext({ pattern: null, items: [], limit: 0, found: [], next: 0 });
|
|
758
|
+
const scan = (pattern, items, limit) => {
|
|
759
|
+
Object.assign(context, { pattern, items, limit, found: [], next: 0 });
|
|
760
|
+
const started = Date.now();
|
|
761
|
+
if (budgetMs > 0) {
|
|
762
|
+
try {
|
|
763
|
+
SCAN.runInContext(context, { timeout: Math.max(1, Math.ceil(budgetMs)) });
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (!error || error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error;
|
|
766
|
+
budgetMs = 0;
|
|
767
|
+
}
|
|
768
|
+
if (budgetMs > 0) budgetMs -= Date.now() - started;
|
|
769
|
+
}
|
|
770
|
+
scan.stopped = budgetMs <= 0;
|
|
771
|
+
return context.found;
|
|
772
|
+
};
|
|
773
|
+
scan.stopped = false;
|
|
774
|
+
return scan;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const TOO_SLOW = 'that pattern takes too long to run here, so use a simpler one';
|
|
778
|
+
|
|
598
779
|
async function doGrep(root, all, input) {
|
|
599
780
|
const regex = new RegExp(String(input.pattern), 'i');
|
|
600
781
|
const matcher = input.glob ? globToRegExp(input.glob) : null;
|
|
601
782
|
const hits = [];
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
783
|
+
const scan = boundedMatcher();
|
|
784
|
+
const candidates = matcher ? scan(matcher, all, Number.MAX_SAFE_INTEGER).map(index => all[index]) : all;
|
|
785
|
+
for (const relative of candidates) {
|
|
786
|
+
if (hits.length >= MAX_GREP_RESULTS || scan.stopped) break;
|
|
787
|
+
if (isMediaPath(relative)) continue;
|
|
605
788
|
let text;
|
|
606
789
|
try {
|
|
607
790
|
const info = await stat(path.join(root, relative));
|
|
608
|
-
|
|
791
|
+
// A named pipe has no size and a read of one never returns.
|
|
792
|
+
if (!info.isFile() || info.size > MAX_READ_BYTES) continue;
|
|
609
793
|
text = await readFile(path.join(root, relative), 'utf8');
|
|
610
794
|
} catch {
|
|
611
795
|
continue;
|
|
612
796
|
}
|
|
797
|
+
if (text.includes(NUL)) continue;
|
|
613
798
|
// A file with secrets in it is not searched either: a grep result is a
|
|
614
799
|
// quoted line, and a quoted line from such a file is the leak.
|
|
615
800
|
if (scanForSecrets(text).length > 0) continue;
|
|
616
801
|
const lines = text.split('\n');
|
|
617
|
-
for (
|
|
618
|
-
|
|
802
|
+
for (const i of scan(regex, lines, MAX_GREP_RESULTS - hits.length)) {
|
|
803
|
+
hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
|
|
619
804
|
}
|
|
620
805
|
}
|
|
806
|
+
if (scan.stopped) return `${hits.length > 0 ? `${hits.join('\n')}\n` : ''}(stopped: ${TOO_SLOW})`;
|
|
621
807
|
if (hits.length === 0) return '(no matches)';
|
|
622
808
|
const capped = hits.length >= MAX_GREP_RESULTS;
|
|
623
809
|
return capped
|
|
@@ -625,6 +811,20 @@ async function doGrep(root, all, input) {
|
|
|
625
811
|
: hits.join('\n');
|
|
626
812
|
}
|
|
627
813
|
|
|
814
|
+
/** The paths a glob matches, under the same limit a grep runs under. */
|
|
815
|
+
function doGlob(all, pattern) {
|
|
816
|
+
const scan = boundedMatcher();
|
|
817
|
+
const matched = scan(globToRegExp(pattern), all, MAX_GLOB_RESULTS).map(index => all[index]);
|
|
818
|
+
if (scan.stopped) return `${matched.length > 0 ? `${matched.join('\n')}\n` : ''}(stopped: ${TOO_SLOW})`;
|
|
819
|
+
return matched.length > 0 ? matched.join('\n') : '(no matches)';
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** No NUL byte, and decoding then encoding gives back exactly these bytes. */
|
|
823
|
+
function isPlainText(buffer) {
|
|
824
|
+
if (buffer.includes(0)) return false;
|
|
825
|
+
return Buffer.from(buffer.toString('utf8'), 'utf8').equals(buffer);
|
|
826
|
+
}
|
|
827
|
+
|
|
628
828
|
function ok(id, text, isError = false) {
|
|
629
829
|
return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
|
|
630
830
|
}
|
package/src/run/report.js
CHANGED
|
@@ -120,7 +120,13 @@ export function stampReport(report, facts) {
|
|
|
120
120
|
// A run the proxy cut off for money did not quit for the reason the model
|
|
121
121
|
// thinks it did. `cost_cap` is in the schema's quit_reason enum precisely
|
|
122
122
|
// for this.
|
|
123
|
-
|
|
123
|
+
//
|
|
124
|
+
// The one thing the runner cannot know is whether a persona that chose to stop
|
|
125
|
+
// had finished what it came for, so the model's own `goal_reached` stands over a
|
|
126
|
+
// plain `persona_quit`. No money reads the difference; a validation's completion
|
|
127
|
+
// measure does, and without this no real run could ever have reached a goal.
|
|
128
|
+
const reachedGoal = facts.quitReason === 'persona_quit' && stamped.quit_reason === 'goal_reached';
|
|
129
|
+
if (facts.quitReason && !reachedGoal) stamped.quit_reason = facts.quitReason;
|
|
124
130
|
// Why it stopped, in the runner's words rather than the model's guess: the
|
|
125
131
|
// action budget ran out, the screen stopped changing, the proxy cut it off.
|
|
126
132
|
if (facts.quitDetail) stamped.quit_detail = facts.quitDetail;
|
package/src/run/resume.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { optionsFrom } from './args.js';
|
|
2
|
+
import { finishJob } from './settle.js';
|
|
2
3
|
import { getDeps } from './deps.js';
|
|
3
4
|
import EXIT from './exit.js';
|
|
4
5
|
import { runsMatchPlan } from './controllers.js';
|
|
@@ -9,6 +10,9 @@ import { createReportValidator, writeReportFiles } from './report.js';
|
|
|
9
10
|
import * as stateFile from './state.js';
|
|
10
11
|
import { playJob, resolvePersonas } from '../commands/run.js';
|
|
11
12
|
|
|
13
|
+
/** Server states that only a live process can be in, and this one is resuming it. */
|
|
14
|
+
const ABANDONED_MID_RUN = Object.freeze(['launching', 'playing']);
|
|
15
|
+
|
|
12
16
|
/**
|
|
13
17
|
* `resumeJob`, the hand-off cli-core's `resume` command makes once it has
|
|
14
18
|
* decided there is something to carry on with.
|
|
@@ -58,6 +62,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
58
62
|
log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
|
|
59
63
|
return EXIT.ENVIRONMENT;
|
|
60
64
|
}
|
|
65
|
+
// Its runs play two extracted commits this path knows nothing about, so playing
|
|
66
|
+
// them here would put both sides on one build.
|
|
67
|
+
if (journal.validate) {
|
|
68
|
+
log(`${jobId} is a validation. Carry on with "ravensight-playtest validate --resume ${jobId}".`);
|
|
69
|
+
return EXIT.ENVIRONMENT;
|
|
70
|
+
}
|
|
61
71
|
const controllers = job.controllers?.length ? job.controllers : null;
|
|
62
72
|
if (journal.job_id !== jobId || journal.game_id !== gameId ||
|
|
63
73
|
JSON.stringify(journal.options?.controllers || null) !== JSON.stringify(controllers) ||
|
|
@@ -87,6 +97,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
87
97
|
journal.runs[row.run_id] = stateFile.newRunEntry({ runId: row.run_id, module: row.module, persona: row.persona });
|
|
88
98
|
}
|
|
89
99
|
const entry = journal.runs[row.run_id];
|
|
100
|
+
// A process killed outright never said it was stopping, and the server refuses
|
|
101
|
+
// `launching` to `launching`, so a replay of this run would die on its first move.
|
|
102
|
+
if (ABANDONED_MID_RUN.includes(row.state)) {
|
|
103
|
+
await api.runs.transition(gameId, jobId, row.run_id, 'interrupted', { reason: 'the process that was playing it is gone' });
|
|
104
|
+
row.state = 'interrupted';
|
|
105
|
+
}
|
|
90
106
|
entry.state = row.state;
|
|
91
107
|
if (stateFile.RUN_DONE_STATES.includes(row.state)) entry.completed = true;
|
|
92
108
|
if (Number.isFinite(Number(row.actions_taken))) entry.actions_taken = Number(row.actions_taken);
|
|
@@ -127,10 +143,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
127
143
|
if (outstanding.length === 0) {
|
|
128
144
|
log(`${finalized.length} interrupted run(s) were finalised from their own transcripts. Nothing left to replay.`);
|
|
129
145
|
if (!isTerminalJob(job)) {
|
|
130
|
-
const settled = await api
|
|
146
|
+
const settled = await finishJob({ api, gameId, jobId, body: { reason: 'resumed and finalised' }, log });
|
|
147
|
+
if (!settled) return EXIT.OK;
|
|
131
148
|
journal.finished = true;
|
|
132
149
|
await stateFile.save(repoRoot, journal);
|
|
133
150
|
log(`job ${jobId} ${settled.job ? settled.job.state : 'finished'}: refunded ${formatCents(settled.refunded_cents)}.`);
|
|
151
|
+
if (settled.job && ['failed', 'stalled'].includes(settled.job.state)) return EXIT.FAILED;
|
|
134
152
|
}
|
|
135
153
|
return EXIT.OK;
|
|
136
154
|
}
|
package/src/run/secretScan.js
CHANGED
|
@@ -169,3 +169,38 @@ export function assertNoSecrets(what, text) {
|
|
|
169
169
|
const secrets = scanForSecrets(text);
|
|
170
170
|
if (secrets.length > 0) throw new SecretsFoundError(what, secrets);
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A changeset whose pull request text does not quote a secret. The text is the
|
|
175
|
+
* model's, written after reading source, and it is the one part of a changeset
|
|
176
|
+
* that can go without: a refusal here would throw away a finished change over its
|
|
177
|
+
* description. So the match goes, the developer is told without being shown it, and
|
|
178
|
+
* `assertNoSecrets` still refuses anything else in the file.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} changeset
|
|
181
|
+
* @param {(message: string) => void} [log]
|
|
182
|
+
* @returns {Object}
|
|
183
|
+
*/
|
|
184
|
+
export function withoutQuotedSecrets(changeset, log = () => {}) {
|
|
185
|
+
const found = scanForSecrets(`${changeset.pr_title || ''}\n${changeset.pr_body || ''}`);
|
|
186
|
+
if (found.length === 0) return changeset;
|
|
187
|
+
log(`The model's pull request text quoted something shaped like a secret (${found.map(item => item.type).join(', ')}). It was removed from the text and the change was kept. If it is a real key, rotate it.`);
|
|
188
|
+
return { ...changeset, pr_title: redactSecrets(changeset.pr_title), pr_body: redactSecrets(changeset.pr_body) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The text with every secret shaped match replaced by `[redacted <type>]`,
|
|
193
|
+
* for local diagnostics that must still be readable. Files that are uploaded
|
|
194
|
+
* use assertNoSecrets and are refused instead.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} text
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function redactSecrets(text) {
|
|
200
|
+
let out = String(text == null ? '' : text);
|
|
201
|
+
for (const { type, regex, accept } of PATTERNS) {
|
|
202
|
+
const pattern = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
203
|
+
out = out.replace(pattern, match => (!accept || accept(match) ? `[redacted ${type}]` : match));
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a job's finish call says when the server could not settle it. 409
|
|
3
|
+
* `settle_contended` is already retried by the HTTP layer on its `Retry-After`, so
|
|
4
|
+
* reaching this means the retries ran out: nothing moved, the job is still open,
|
|
5
|
+
* and it is not this CLI's to settle any other way.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @param {string} jobId */
|
|
9
|
+
export function stillOpenLine(jobId) {
|
|
10
|
+
return `job ${jobId} is still open: the server could not settle it after several tries, and no money moved. The hourly sweep will settle it, so there is nothing to do.`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} jobId
|
|
15
|
+
* @param {unknown} error
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function settleFailureLine(jobId, error) {
|
|
19
|
+
if (error && error.code === 'settle_contended') return stillOpenLine(jobId);
|
|
20
|
+
return `the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `api.jobs.finish`, except a contended settle is an answer rather than a crash at
|
|
25
|
+
* the end of a run that already did its work.
|
|
26
|
+
*
|
|
27
|
+
* @returns {Promise<Object|null>} the finish answer, or null when the job is still open
|
|
28
|
+
*/
|
|
29
|
+
export async function finishJob({ api, gameId, jobId, body, log }) {
|
|
30
|
+
try {
|
|
31
|
+
const finished = await api.jobs.finish(gameId, jobId, body);
|
|
32
|
+
if (finished && finished.refund_limited_note) log(finished.refund_limited_note);
|
|
33
|
+
return finished;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!error || error.code !== 'settle_contended') throw error;
|
|
36
|
+
log(stillOpenLine(jobId));
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default { stillOpenLine, settleFailureLine, finishJob };
|
package/src/run/tools.js
CHANGED
|
@@ -35,6 +35,12 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
|
|
|
35
35
|
/** Severities, from the report schema's finding severity enum. */
|
|
36
36
|
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
37
37
|
|
|
38
|
+
/** The longest a persona may hold a key in one action (godot_driver's own cap). */
|
|
39
|
+
export const MAX_HOLD_MS = 5000;
|
|
40
|
+
|
|
41
|
+
/** Extra keys one key action may hold together with `key`, for a diagonal. */
|
|
42
|
+
export const MAX_WITH_KEYS = 2;
|
|
43
|
+
|
|
38
44
|
/** Tools that spend an action. */
|
|
39
45
|
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
|
|
40
46
|
|
|
@@ -111,13 +117,15 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
111
117
|
},
|
|
112
118
|
{
|
|
113
119
|
name: 'key',
|
|
114
|
-
description:
|
|
120
|
+
description: `Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead. A press without hold_ms is a tap of about one frame, which moves a character a pixel or two: to walk or run, set hold_ms (up to ${MAX_HOLD_MS}) and the key is held that long while the game runs. with_keys holds more keys at the same time, such as with_keys ["s"] with key "d" to move diagonally. On godot_driver the game is paused between your actions and runs only while one executes, so thinking costs no game time.`,
|
|
115
121
|
input_schema: {
|
|
116
122
|
type: 'object',
|
|
117
123
|
additionalProperties: false,
|
|
118
124
|
required: ['key'],
|
|
119
125
|
properties: {
|
|
120
126
|
key: { type: 'string' },
|
|
127
|
+
hold_ms: { type: 'integer', minimum: 0, maximum: MAX_HOLD_MS, description: 'How long to hold the key down, in milliseconds of game time. Leave out for a tap.' },
|
|
128
|
+
with_keys: { type: 'array', maxItems: MAX_WITH_KEYS, items: { type: 'string' }, description: 'Keys held down together with key for the same time, such as ["s"] for a diagonal.' },
|
|
121
129
|
thought_in_character: thought,
|
|
122
130
|
note
|
|
123
131
|
}
|
|
@@ -205,8 +213,14 @@ export function actionFor(toolUse) {
|
|
|
205
213
|
if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
|
|
206
214
|
return action;
|
|
207
215
|
}
|
|
208
|
-
case 'key':
|
|
209
|
-
|
|
216
|
+
case 'key': {
|
|
217
|
+
const action = { kind: 'key', key: String(input.key || '') };
|
|
218
|
+
const hold = Math.min(Math.round(Number(input.hold_ms) || 0), MAX_HOLD_MS);
|
|
219
|
+
if (hold > 0) action.holdMs = hold;
|
|
220
|
+
const extra = Array.isArray(input.with_keys) ? input.with_keys.filter(key => typeof key === 'string' && key.trim()).slice(0, MAX_WITH_KEYS) : [];
|
|
221
|
+
if (extra.length > 0) action.keys = extra;
|
|
222
|
+
return action;
|
|
223
|
+
}
|
|
210
224
|
case 'navigate':
|
|
211
225
|
return { kind: 'navigate', url: String(input.url || '') };
|
|
212
226
|
case 'wait':
|
|
@@ -251,6 +265,16 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
251
265
|
if (toolUse.name === 'screenshot') {
|
|
252
266
|
try {
|
|
253
267
|
const shot = await driver.screenshot(String(input.name || 'shot'));
|
|
268
|
+
// Saved nothing (headless Godot): no file to list, cite or claim.
|
|
269
|
+
if (!shot || !shot.relativePath) {
|
|
270
|
+
return {
|
|
271
|
+
result: toolResult(toolUse.id, `No screenshot was saved here (${(shot && shot.skipped) || 'the driver returned no file'}), so there is nothing to cite. Describe what you saw in a note.`),
|
|
272
|
+
observation: null,
|
|
273
|
+
spentAction: false,
|
|
274
|
+
changed: false,
|
|
275
|
+
error: null
|
|
276
|
+
};
|
|
277
|
+
}
|
|
254
278
|
if (onScreenshot) onScreenshot({ ...shot, reason: String(input.reason || '') });
|
|
255
279
|
return {
|
|
256
280
|
result: toolResult(toolUse.id, `Saved ${shot.relativePath}.`),
|
|
@@ -301,7 +325,12 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
301
325
|
// before anything reaches the game.
|
|
302
326
|
// A ref in a later tool call of a turn whose earlier call already changed
|
|
303
327
|
// the screen was read off a screen that is gone.
|
|
304
|
-
|
|
328
|
+
// A hold sent to a driver that can only tap would be a one frame tap the
|
|
329
|
+
// persona believes was a walk, which is the 0.5.0 Godot failure in reverse.
|
|
330
|
+
const wantsHold = action.kind === 'key' && (action.holdMs > 0 || (action.keys && action.keys.length > 0));
|
|
331
|
+
const outcome = wantsHold && !driver.supportsHold
|
|
332
|
+
? { ok: false, changed: false, error: `${driver.name || 'this driver'} cannot hold keys; send key without hold_ms or with_keys`, driverError: true }
|
|
333
|
+
: action.ref && !driver.supportsRefs
|
|
305
334
|
? { ok: false, changed: false, error: `ref targets are only available on playwright_web; ${driver.name || 'this driver'} takes a named target or coordinates`, driverError: true }
|
|
306
335
|
: action.ref && staleRefs
|
|
307
336
|
? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
|