ravensight-playtest 0.7.2 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/package.json +3 -3
- package/src/cli.js +9 -1
- package/src/commands/brief.js +1 -1
- package/src/commands/build.js +23 -1
- package/src/commands/hosted.js +2 -1
- package/src/hosted/buildStatus.js +35 -0
- package/src/hosted/buildUpload.js +24 -2
- package/src/hosted/recordedRun.js +3 -0
- package/src/run/context.js +122 -3
- package/src/run/drivers/web.js +326 -133
- package/src/run/model.js +18 -1
- package/src/run/openPr.js +8 -1
- package/src/run/personaLoop.js +78 -17
- package/src/run/tools.js +31 -3
- package/src/ui/index.js +21 -6
package/README.md
CHANGED
|
@@ -66,14 +66,20 @@ The hosted build command source adds a local check before a paid upload:
|
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
68
|
ravensight-playtest build preflight ./game.zip --format web --entrypoint index.html
|
|
69
|
-
ravensight-playtest build upload ./game.zip --game GAME --
|
|
69
|
+
ravensight-playtest build upload ./game.zip --game GAME --entrypoint index.html --dry-run
|
|
70
|
+
ravensight-playtest build status --game GAME
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
`preflight` seeks through bounded ZIP metadata; it does not load a multi-GB
|
|
73
74
|
archive into memory. A saved server profile can be supplied with `--capacity`
|
|
74
|
-
for its declared limits. Upload
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
for its declared limits. Upload uses the server's build profile when it
|
|
76
|
+
advertises exactly one; when it advertises several, name one with `--profile`
|
|
77
|
+
and the error lists them. It checks metadata and a streamed checksum, then
|
|
78
|
+
asks for the separate $0.49 validation hold before requesting it. Without a
|
|
79
|
+
terminal to ask in, pass `--yes`: a piped answer is never taken as consent.
|
|
80
|
+
`build status` shows each build's state and, for a build the server's check
|
|
81
|
+
refused, the reason in one sentence and whether its validation hold is still
|
|
82
|
+
to be returned after cleanup. The server’s isolated validator remains
|
|
77
83
|
authoritative. A passing local check does not establish engine, GPU or license
|
|
78
84
|
compatibility.
|
|
79
85
|
|
|
@@ -1109,8 +1115,16 @@ at the current $1 unit price total $2; the displayed server quote is authoritati
|
|
|
1109
1115
|
No comparison model call or extra recording fee is added. Use `--upload-video`
|
|
1110
1116
|
instead of `--record-video` only when you want the videos uploaded for private
|
|
1111
1117
|
dashboard playback. Recording is silent and captures only the selected game,
|
|
1112
|
-
not your desktop.
|
|
1113
|
-
|
|
1118
|
+
not your desktop.
|
|
1119
|
+
|
|
1120
|
+
A web persona stays on the build, recording or not. A clicked link, a form, a
|
|
1121
|
+
script or a server redirect to another site, or to another port on your
|
|
1122
|
+
machine, is stopped (a redirect is undone), and a new window is closed. The
|
|
1123
|
+
persona is told "Refused: that link leaves the build under test (host). Stay
|
|
1124
|
+
on this page and use its controls." and the step counts as one that changed
|
|
1125
|
+
nothing. Each attempt is a network note in the report. `localhost`,
|
|
1126
|
+
`127.0.0.1` and `[::1]` on the build's own port are the build. Assets and API
|
|
1127
|
+
calls the page loads from elsewhere are not affected.
|
|
1114
1128
|
|
|
1115
1129
|
Web recording produces WebM. Graphical Godot recording needs an existing FFmpeg
|
|
1116
1130
|
installation and produces MP4; a headless Godot run or missing encoder is reported
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ravensight-playtest",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://ravensight.io/docs/#playtest",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"./api": "./src/api/index.js"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
-
"test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|scaffold.godot|template3d.godot).test.js'",
|
|
49
|
+
"test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|web.containment.e2e|scaffold.godot|template3d.godot).test.js'",
|
|
50
50
|
"test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js src/create/scaffold.godot.test.js src/create/template3d.godot.test.js",
|
|
51
|
-
"test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
|
|
51
|
+
"test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js src/run/drivers/web.containment.e2e.test.js",
|
|
52
52
|
"lint": "node scripts/no-dashes.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
package/src/cli.js
CHANGED
|
@@ -211,13 +211,21 @@ export function buildProgram() {
|
|
|
211
211
|
const { buildPreflight } = await import('./commands/build.js');
|
|
212
212
|
return buildPreflight(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, format: options.format, capacity: options.capacity }));
|
|
213
213
|
}));
|
|
214
|
+
build.command('status [build-id]')
|
|
215
|
+
.description('show each private hosted build, why a refused one was refused, and whether its hold is still to be returned')
|
|
216
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
217
|
+
.action(capture(slot, async (buildId, options, command) => {
|
|
218
|
+
const { buildStatus } = await import('./commands/build.js');
|
|
219
|
+
return buildStatus(buildId, flagsFrom(command.optsWithGlobals(), { game: options.game }));
|
|
220
|
+
}));
|
|
214
221
|
build.command('upload <archive>')
|
|
215
222
|
.description('preflight and stream one private build upload after validation-hold consent')
|
|
216
|
-
.
|
|
223
|
+
.option('--profile <version>', 'server build profile to upload under; optional when the server advertises exactly one, otherwise the error lists the choices')
|
|
217
224
|
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
218
225
|
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
219
226
|
.option('--journal <file>', 'preserve this original upload intent at the specified path')
|
|
220
227
|
.option('--dry-run', 'inspect and hash the archive without requesting a reservation')
|
|
228
|
+
.addHelpText('after', '\nThe $0.49 validation hold is asked for at a terminal. Without one, pass --yes\nto accept it; piped input is never taken as consent. See the result with: ravensight-playtest build status')
|
|
221
229
|
.action(capture(slot, async (archive, options, command) => {
|
|
222
230
|
const { buildUpload } = await import('./commands/build.js');
|
|
223
231
|
return buildUpload(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, profile: options.profile, game: options.game, journal: options.journal, dryRun: options.dryRun }));
|
package/src/commands/brief.js
CHANGED
|
@@ -273,7 +273,7 @@ async function editBrief(context, flags, deps) {
|
|
|
273
273
|
parentVersion,
|
|
274
274
|
onStale: async version => {
|
|
275
275
|
ui.warn(`The brief moved to version ${version} while you were editing.`);
|
|
276
|
-
return ui.confirm('Save on top of that version?', { yes: flags.yes });
|
|
276
|
+
return ui.confirm('Save on top of that version?', { yes: flags.yes, noTerminalMessage: 'No terminal to confirm in: pass --yes to save on top of the newer version.' });
|
|
277
277
|
}
|
|
278
278
|
});
|
|
279
279
|
|
package/src/commands/build.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveGameId } from '../config.js';
|
|
|
4
4
|
import { withAuthHandling } from '../auth/session.js';
|
|
5
5
|
import { preflightBuildArchive, limitsFromProfile } from '../hosted/archivePreflight.js';
|
|
6
6
|
import { uploadHostedBuild } from '../hosted/buildUpload.js';
|
|
7
|
+
import { buildStatusLines, readHostedBuilds } from '../hosted/buildStatus.js';
|
|
7
8
|
import { CliError, ExitCode } from '../errors.js';
|
|
8
9
|
import { ui } from '../ui/index.js';
|
|
9
10
|
|
|
@@ -61,6 +62,7 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
|
61
62
|
onPrepared: prepared => {
|
|
62
63
|
progress.stop();
|
|
63
64
|
if (flags.json) return;
|
|
65
|
+
ui.info(`Build profile: ${prepared.profileVersion}${flags.profile ? '' : ', the only one the server advertises'}.`);
|
|
64
66
|
ui.info(`ZIP metadata checked: ${prepared.entries} entries; ${ui.bytes(prepared.archiveBytes)} archive, ${ui.bytes(prepared.expandedBytes)} expanded.`);
|
|
65
67
|
const limit = prepared.limits;
|
|
66
68
|
ui.info(`Selected limits: archive ${ui.bytes(limit.archiveBytes)}, expanded ${ui.bytes(limit.expandedBytes)}, file ${ui.bytes(limit.fileBytes)}, ${limit.files} entries, depth ${limit.depth}, ratio ${limit.ratio}:1.`);
|
|
@@ -73,9 +75,29 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
|
73
75
|
});
|
|
74
76
|
progress.stop();
|
|
75
77
|
if (flags.json) ui.json(result);
|
|
76
|
-
else if (result.state === 'awaiting_validation')
|
|
78
|
+
else if (result.state === 'awaiting_validation') {
|
|
79
|
+
ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
|
|
80
|
+
ui.info(`See the result, and the reason if it is not accepted: ravensight-playtest build status ${result.buildId}`);
|
|
81
|
+
}
|
|
77
82
|
else ui.info(result.state === 'declined' ? 'Upload declined; no reservation requested.' : 'Preflight finished; no reservation requested.');
|
|
78
83
|
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
79
84
|
} finally { progress.stop(); }
|
|
80
85
|
});
|
|
81
86
|
}
|
|
87
|
+
|
|
88
|
+
// State of the game's private hosted builds, with the reason a refused build
|
|
89
|
+
// was refused and whether its validation hold is still to be returned.
|
|
90
|
+
export async function buildStatus(buildId, flags = {}, deps = {}) {
|
|
91
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
92
|
+
return withAuthHandling(context, async () => {
|
|
93
|
+
const builds = await readHostedBuilds({ api: context.api, gameId: resolveGameId(flags, context.config), buildId });
|
|
94
|
+
if (flags.json) { ui.json({ builds }); return ExitCode.OK; }
|
|
95
|
+
if (!builds.length) {
|
|
96
|
+
if (buildId) throw new CliError(`No hosted build ${buildId} for this game. A refused build is listed until its cleanup finishes.`);
|
|
97
|
+
ui.info('No private hosted builds for this game.');
|
|
98
|
+
return ExitCode.OK;
|
|
99
|
+
}
|
|
100
|
+
for (const build of builds) buildStatusLines(build).forEach(text => ui.info(text));
|
|
101
|
+
return ExitCode.OK;
|
|
102
|
+
});
|
|
103
|
+
}
|
package/src/commands/hosted.js
CHANGED
|
@@ -162,7 +162,8 @@ export async function hostedCancel(admissionId, flags = {}, deps = {}) {
|
|
|
162
162
|
const now = deps.now || Date.now;
|
|
163
163
|
return withAuthHandling(context, async () => {
|
|
164
164
|
const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
|
|
165
|
-
const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes)
|
|
165
|
+
const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes),
|
|
166
|
+
noTerminalMessage: 'No terminal to confirm in: pass --yes to cancel this run.' }));
|
|
166
167
|
if (!await confirm(`Cancel hosted run ${admissionId}? Work already completed follows the accepted policy; unstarted work is refunded during settlement.`)) {
|
|
167
168
|
if (flags.json) ui.json({ state: 'kept', admissionId });
|
|
168
169
|
else ui.info('Nothing was canceled.');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { printable } from '../ui/index.js';
|
|
2
|
+
|
|
3
|
+
const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
|
|
4
|
+
|
|
5
|
+
const LABELS = {
|
|
6
|
+
uploading: 'uploading',
|
|
7
|
+
awaiting_validation: 'waiting for its archive check',
|
|
8
|
+
ready: 'ready',
|
|
9
|
+
graphical_static_validated: 'archive check passed',
|
|
10
|
+
rejected: 'not accepted',
|
|
11
|
+
deleting: 'being removed'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Server text is one fixed sentence; still print it as one plain line.
|
|
15
|
+
const line = text => printable(String(text || '')).replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
16
|
+
|
|
17
|
+
/** The plain lines that describe one build: its state, why it was refused, and the hold. */
|
|
18
|
+
export function buildStatusLines(build) {
|
|
19
|
+
const state = build?.state === 'deleting' && build.rejection ? 'not accepted, being removed' : (LABELS[build?.state] || line(build?.state) || 'unknown');
|
|
20
|
+
const lines = [`${line(build?.buildId)}${build?.entrypoint ? ` (${line(build.entrypoint)})` : ''}: ${state}.`];
|
|
21
|
+
if (build?.rejection?.message) lines.push(` Why: ${line(build.rejection.message)}`);
|
|
22
|
+
else if (build?.state === 'awaiting_validation') lines.push(' Validation is required before a run. Check again in a minute.');
|
|
23
|
+
if (build?.holdReturn?.message) lines.push(` ${line(build.holdReturn.message)}`);
|
|
24
|
+
if (build?.safetyHold) lines.push(' A run of this build was stopped by the safety check, so it cannot be quoted until that hold is cleared.');
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The game's private hosted builds, newest first, optionally one of them. */
|
|
29
|
+
export async function readHostedBuilds({ api, gameId, buildId }) {
|
|
30
|
+
const answer = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
|
|
31
|
+
const builds = (Array.isArray(answer.body?.builds) ? answer.body.builds : [])
|
|
32
|
+
.filter(build => build && typeof build.buildId === 'string')
|
|
33
|
+
.sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
|
|
34
|
+
return buildId ? builds.filter(build => build.buildId === buildId) : builds;
|
|
35
|
+
}
|
|
@@ -42,6 +42,28 @@ export async function putArchive(archive, transfer, { fetchImpl = globalThis.fet
|
|
|
42
42
|
} finally { stream.destroy(); }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The profile to upload under: the one named, or the only one advertised. With
|
|
47
|
+
* several and none named, the error lists them so the person can pick one.
|
|
48
|
+
*/
|
|
49
|
+
export function chooseProfile(profiles, requested) {
|
|
50
|
+
const advertised = profiles.filter(profile => typeof profile?.profileVersion === 'string' && profile.profileVersion);
|
|
51
|
+
const names = [...new Set(advertised.map(profile => profile.profileVersion))];
|
|
52
|
+
const list = names.map(name => {
|
|
53
|
+
const format = advertised.find(profile => profile.profileVersion === name).format;
|
|
54
|
+
return format ? `${name} (${format})` : name;
|
|
55
|
+
}).join(', ');
|
|
56
|
+
if (!names.length) fail('No build profile is advertised for this game right now. Nothing was charged.');
|
|
57
|
+
if (!requested) {
|
|
58
|
+
if (names.length === 1) return names[0];
|
|
59
|
+
fail(`Several build profiles are available. Choose one with --profile: ${list}. Nothing was charged.`);
|
|
60
|
+
}
|
|
61
|
+
if (advertised.filter(profile => profile.profileVersion === requested).length !== 1) {
|
|
62
|
+
fail(`"${requested}" is not an advertised build profile. Choose one with --profile: ${list}. Nothing was charged.`);
|
|
63
|
+
}
|
|
64
|
+
return requested;
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
async function journalOnce(filename, value) {
|
|
46
68
|
await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
47
69
|
let handle;
|
|
@@ -58,13 +80,13 @@ async function journalOnce(filename, value) {
|
|
|
58
80
|
// the only wallet-mutating POST. No model or gameplay API is called here.
|
|
59
81
|
export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
|
|
60
82
|
apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
|
|
61
|
-
if (!gameId ||
|
|
83
|
+
if (!gameId || typeof confirm !== 'function') fail('Choose a game and upload confirmation.');
|
|
62
84
|
onProgress({ phase: 'capabilities' });
|
|
63
85
|
const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
|
|
64
86
|
const profiles = caps?.recorded?.profiles;
|
|
65
87
|
if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
|
|
88
|
+
profileVersion = chooseProfile(profiles, profileVersion);
|
|
66
89
|
const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
|
|
67
|
-
if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
|
|
68
90
|
const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
|
|
69
91
|
const offer = profile.reservationOffer;
|
|
70
92
|
if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { CliError, ExitCode } from '../errors.js';
|
|
5
5
|
import { ApiError } from '../api/errors.js';
|
|
6
6
|
import { stableStringify } from '../fsutil.js';
|
|
7
|
+
import { printable } from '../ui/index.js';
|
|
7
8
|
|
|
8
9
|
const MAX_QUOTE_BYTES = 1024 * 1024;
|
|
9
10
|
const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
|
|
@@ -259,6 +260,8 @@ export async function prepareHostedRun({ api, apiUrl, gameId, buildId, persona,
|
|
|
259
260
|
let build;
|
|
260
261
|
if (buildId) {
|
|
261
262
|
build = eligible.find(row => row.buildId === buildId);
|
|
263
|
+
const refused = (Array.isArray(listed.body?.builds) ? listed.body.builds : []).find(row => row?.buildId === buildId && row.rejection?.message);
|
|
264
|
+
if (!build && refused) fail(`Build ${buildId} was not accepted: ${printable(String(refused.rejection.message)).replace(/\s+/g, ' ').slice(0, 300)} Nothing was charged.`);
|
|
262
265
|
if (!build) fail(`Build ${buildId} is not ready for a hosted run: it has to pass its static archive check on a ready profile and not have expired. Nothing was charged.`);
|
|
263
266
|
} else {
|
|
264
267
|
build = eligible[0];
|
package/src/run/context.js
CHANGED
|
@@ -165,17 +165,136 @@ export function pruneObservations(messages, keep = KEEP_OBSERVATIONS) {
|
|
|
165
165
|
return out;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** What a tool_result with nothing in it is sent as. */
|
|
169
|
+
export const EMPTY_TOOL_RESULT_TEXT = '(no output)';
|
|
170
|
+
|
|
171
|
+
/** The tool_result for a tool_use the runner never answered. */
|
|
172
|
+
export const MISSING_TOOL_RESULT_TEXT = 'Not run: the runner did not play this call.';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Make every tool_result in the conversation one the Messages API accepts.
|
|
176
|
+
*
|
|
177
|
+
* The rules the API enforces, and what each repair is:
|
|
178
|
+
*
|
|
179
|
+
* - **An `is_error` result is text only.** A result flagged `is_error` that
|
|
180
|
+
* also carries an image block is refused with a 400. That is the 0.7.2
|
|
181
|
+
* failure: a refused off-site `navigate` on a canvas build came back as
|
|
182
|
+
* "That did not work: ..." plus the screenshot, flagged as an error, and
|
|
183
|
+
* every persona of the job died on its next turn. The screenshot is the
|
|
184
|
+
* persona's only view of a canvas game, so the image stays and the flag
|
|
185
|
+
* goes: the text already says the action did not work.
|
|
186
|
+
* - **Content is never empty.** An empty string, an empty array or a text
|
|
187
|
+
* block with no text becomes {@link EMPTY_TOOL_RESULT_TEXT}; a non-string,
|
|
188
|
+
* non-array content becomes its string form.
|
|
189
|
+
* - **Exactly one result per tool_use, in the very next user message.** A
|
|
190
|
+
* missing result is added ({@link MISSING_TOOL_RESULT_TEXT}); a second
|
|
191
|
+
* result for one id, and a result whose id the assistant turn before it
|
|
192
|
+
* never used, are dropped. A user message left with nothing gets the empty
|
|
193
|
+
* text, because an empty message is refused too.
|
|
194
|
+
*
|
|
195
|
+
* A new array; the input is not mutated.
|
|
196
|
+
*
|
|
197
|
+
* @param {Array} messages
|
|
198
|
+
* @returns {Array}
|
|
199
|
+
*/
|
|
200
|
+
export function validToolResults(messages) {
|
|
201
|
+
const out = messages.map(message => message);
|
|
202
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
203
|
+
const message = out[i];
|
|
204
|
+
if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
|
|
205
|
+
const previous = i > 0 ? out[i - 1] : null;
|
|
206
|
+
const expected = previous && previous.role === 'assistant' && Array.isArray(previous.content)
|
|
207
|
+
? previous.content.filter(block => block && block.type === 'tool_use').map(block => block.id)
|
|
208
|
+
: [];
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const content = [];
|
|
211
|
+
for (const block of message.content) {
|
|
212
|
+
if (!block || block.type !== 'tool_result') {
|
|
213
|
+
content.push(block);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!expected.includes(block.tool_use_id) || seen.has(block.tool_use_id)) continue;
|
|
217
|
+
seen.add(block.tool_use_id);
|
|
218
|
+
content.push(validToolResult(block));
|
|
219
|
+
}
|
|
220
|
+
const missing = expected.filter(id => !seen.has(id))
|
|
221
|
+
.map(id => ({ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: MISSING_TOOL_RESULT_TEXT }] }));
|
|
222
|
+
// Results go first in the message: the API wants them before any text.
|
|
223
|
+
const results = [...content.filter(block => block && block.type === 'tool_result'), ...missing];
|
|
224
|
+
const rest = content.filter(block => !(block && block.type === 'tool_result'));
|
|
225
|
+
const next = [...results, ...rest];
|
|
226
|
+
out[i] = { ...message, content: next.length > 0 ? next : [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }] };
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One tool_result made valid. See {@link validToolResults}.
|
|
233
|
+
*
|
|
234
|
+
* @param {Object} block
|
|
235
|
+
* @returns {Object}
|
|
236
|
+
*/
|
|
237
|
+
export function validToolResult(block) {
|
|
238
|
+
let content = block.content;
|
|
239
|
+
if (typeof content === 'string') {
|
|
240
|
+
content = content.trim() ? content : EMPTY_TOOL_RESULT_TEXT;
|
|
241
|
+
} else if (Array.isArray(content)) {
|
|
242
|
+
content = content
|
|
243
|
+
.filter(child => child && (child.type !== 'text' || (typeof child.text === 'string' && child.text.trim())))
|
|
244
|
+
.map(child => child);
|
|
245
|
+
if (content.length === 0) content = [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }];
|
|
246
|
+
} else {
|
|
247
|
+
content = content === undefined || content === null || String(content).trim() === '' ? EMPTY_TOOL_RESULT_TEXT : String(content);
|
|
248
|
+
}
|
|
249
|
+
const result = { ...block, content };
|
|
250
|
+
if (result.is_error && Array.isArray(content) && content.some(child => child.type !== 'text')) delete result.is_error;
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every tool_result in the newest user message rewritten as plain text with
|
|
256
|
+
* no `is_error` flag and no image: the one repair the persona loop makes
|
|
257
|
+
* when the provider refuses a turn it cannot otherwise explain, before it
|
|
258
|
+
* gives up on the conversation. The text keeps what the persona was told.
|
|
259
|
+
*
|
|
260
|
+
* @param {Array} messages
|
|
261
|
+
* @returns {Array} a new array; the input is not mutated
|
|
262
|
+
*/
|
|
263
|
+
export function repairLastTurn(messages) {
|
|
264
|
+
const out = validToolResults(messages);
|
|
265
|
+
for (let i = out.length - 1; i >= 0; i -= 1) {
|
|
266
|
+
const message = out[i];
|
|
267
|
+
if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
|
|
268
|
+
out[i] = {
|
|
269
|
+
...message,
|
|
270
|
+
content: message.content.map(block => {
|
|
271
|
+
if (!block || block.type !== 'tool_result') return block;
|
|
272
|
+
const texts = Array.isArray(block.content)
|
|
273
|
+
? block.content.filter(child => child.type === 'text').map(child => child.text)
|
|
274
|
+
: [String(block.content)];
|
|
275
|
+
const text = texts.join('\n').trim() || EMPTY_TOOL_RESULT_TEXT;
|
|
276
|
+
const { is_error: _flag, ...rest } = block;
|
|
277
|
+
return { ...rest, content: [{ type: 'text', text }] };
|
|
278
|
+
})
|
|
279
|
+
};
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
168
285
|
/**
|
|
169
286
|
* Both ceilings, in the order that matters: drop stale observations first
|
|
170
287
|
* (which removes their images too), then enforce the image budget on what
|
|
171
|
-
* is left
|
|
288
|
+
* is left, then make every tool_result one the API accepts (which has to
|
|
289
|
+
* come last: whether an `is_error` result still carries an image is only
|
|
290
|
+
* known once the image budget is applied).
|
|
172
291
|
*
|
|
173
292
|
* @param {Array} messages
|
|
174
293
|
* @param {{maxImages?: number, keepObservations?: number}} [opts]
|
|
175
294
|
* @returns {Array}
|
|
176
295
|
*/
|
|
177
296
|
export function prepareMessages(messages, { maxImages = MAX_IMAGES_IN_CONTEXT, keepObservations = KEEP_OBSERVATIONS } = {}) {
|
|
178
|
-
return pruneImages(pruneObservations(messages, keepObservations), maxImages);
|
|
297
|
+
return validToolResults(pruneImages(pruneObservations(messages, keepObservations), maxImages));
|
|
179
298
|
}
|
|
180
299
|
|
|
181
300
|
/**
|
|
@@ -189,4 +308,4 @@ export function shrinkImages(current) {
|
|
|
189
308
|
return Math.max(0, Math.floor(current / 2));
|
|
190
309
|
}
|
|
191
310
|
|
|
192
|
-
export default { prepareMessages, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };
|
|
311
|
+
export default { prepareMessages, validToolResults, validToolResult, repairLastTurn, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };
|
package/src/run/drivers/web.js
CHANGED
|
@@ -866,21 +866,24 @@ async function loadPlaywright(importer) {
|
|
|
866
866
|
}
|
|
867
867
|
|
|
868
868
|
/**
|
|
869
|
-
* Whether the
|
|
870
|
-
*
|
|
871
|
-
* the
|
|
872
|
-
*
|
|
869
|
+
* Whether the page may be at this address: the build's own origin, and
|
|
870
|
+
* nothing else. It decides the `navigate` tool and every top-level
|
|
871
|
+
* navigation the page itself starts (a clicked link, a form, a script, a
|
|
872
|
+
* server redirect), recording or not.
|
|
873
873
|
*
|
|
874
874
|
* Game text is untrusted input: a level name or a leaderboard entry can
|
|
875
|
-
* carry "go to https://.../ and paste your token". Confining
|
|
875
|
+
* carry "go to https://.../ and paste your token". Confining the page to
|
|
876
876
|
* the target's own origin means the worst such an instruction can achieve
|
|
877
877
|
* is a page of the developer's own game.
|
|
878
878
|
*
|
|
879
|
-
* The
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
879
|
+
* The one allowance is a loopback alias: when the build is on loopback,
|
|
880
|
+
* `localhost`, `127.0.0.1` and `[::1]` on the SAME scheme and port are the
|
|
881
|
+
* same server, so a page that links to itself by another loopback name is
|
|
882
|
+
* still the build. Another port is another server (a dev server, an admin
|
|
883
|
+
* panel, a mirror of the live site) and is off the build. Subresources are
|
|
884
|
+
* not decided here: a page may still load assets or call an API on another
|
|
885
|
+
* port. A hosted build never gets the loopback alias: `localhost` there is
|
|
886
|
+
* whatever else the developer happens to be running.
|
|
884
887
|
*
|
|
885
888
|
* @param {string} candidate
|
|
886
889
|
* @param {string} target
|
|
@@ -897,7 +900,35 @@ export function isNavigationAllowed(candidate, target) {
|
|
|
897
900
|
}
|
|
898
901
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
899
902
|
if (url.origin === base.origin) return true;
|
|
900
|
-
return isLoopback(base.hostname) && isLoopback(url.hostname)
|
|
903
|
+
return isLoopback(base.hostname) && isLoopback(url.hostname)
|
|
904
|
+
&& url.protocol === base.protocol && effectivePort(url) === effectivePort(base);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* @param {URL} url
|
|
909
|
+
* @returns {string}
|
|
910
|
+
*/
|
|
911
|
+
function effectivePort(url) {
|
|
912
|
+
return url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The most off-site attempts one run keeps for its notes. */
|
|
916
|
+
export const MAX_OFF_SITE_ATTEMPTS = 50;
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The host an off-site navigation asked for, for the persona's refusal text.
|
|
920
|
+
* Never the full address: a query string can carry anything.
|
|
921
|
+
*
|
|
922
|
+
* @param {string} candidate
|
|
923
|
+
* @returns {string}
|
|
924
|
+
*/
|
|
925
|
+
export function offSiteHost(candidate) {
|
|
926
|
+
try {
|
|
927
|
+
const url = new URL(candidate);
|
|
928
|
+
return url.host || url.protocol.replace(/:$/, '');
|
|
929
|
+
} catch {
|
|
930
|
+
return 'an address that is not a web page';
|
|
931
|
+
}
|
|
901
932
|
}
|
|
902
933
|
|
|
903
934
|
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
@@ -1171,6 +1202,68 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1171
1202
|
const consoleBuffer = [];
|
|
1172
1203
|
/** Recent failed requests, newest last, bounded: what a console line is matched against. */
|
|
1173
1204
|
const networkFailures = [];
|
|
1205
|
+
// Containment. The build's addresses (the target, and where its first
|
|
1206
|
+
// load landed), every attempt to leave them since the last drain, and
|
|
1207
|
+
// the last address on the build, for a recovery.
|
|
1208
|
+
let buildTargets = [];
|
|
1209
|
+
let guardArmed = false;
|
|
1210
|
+
const offSite = [];
|
|
1211
|
+
let offSiteCount = 0;
|
|
1212
|
+
let lastBuildUrl = null;
|
|
1213
|
+
let recovery = null;
|
|
1214
|
+
|
|
1215
|
+
/** @param {string} candidate */
|
|
1216
|
+
function onBuild(candidate) {
|
|
1217
|
+
return buildTargets.some(base => isNavigationAllowed(candidate, base));
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* @param {string} url
|
|
1222
|
+
* @param {string} via
|
|
1223
|
+
*/
|
|
1224
|
+
function recordOffSite(url, via) {
|
|
1225
|
+
offSiteCount += 1;
|
|
1226
|
+
offSite.push({ host: offSiteHost(url), url: networkUrl(url), via, claimed: false });
|
|
1227
|
+
while (offSite.length > MAX_OFF_SITE_ATTEMPTS) offSite.shift();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* A top-level document landed off the build anyway: a server redirect,
|
|
1232
|
+
* which a route never sees after its first hop. Go back, and if that does
|
|
1233
|
+
* not land on the build, load the last address that did.
|
|
1234
|
+
* @param {any} current
|
|
1235
|
+
*/
|
|
1236
|
+
async function recoverToBuild(current) {
|
|
1237
|
+
try {
|
|
1238
|
+
await current.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
1239
|
+
} catch {
|
|
1240
|
+
// Nothing to go back to, or the page would not: load the build instead.
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
if (!onBuild(current.url()) && lastBuildUrl) await current.goto(lastBuildUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1244
|
+
} catch {
|
|
1245
|
+
// The next observation shows where the page is; act refuses from there.
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Any recovery in flight, settled. */
|
|
1250
|
+
async function settled() {
|
|
1251
|
+
while (recovery) {
|
|
1252
|
+
const pending = recovery;
|
|
1253
|
+
await pending;
|
|
1254
|
+
if (recovery === pending) recovery = null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** @param {any} request */
|
|
1259
|
+
function isTopLevel(request) {
|
|
1260
|
+
try {
|
|
1261
|
+
return !request.frame().parentFrame();
|
|
1262
|
+
} catch {
|
|
1263
|
+
// A new window's first request is issued before its frame exists.
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1174
1267
|
|
|
1175
1268
|
/** @returns {any} */
|
|
1176
1269
|
function requirePage() {
|
|
@@ -1439,6 +1532,136 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1439
1532
|
finally { inspecting = false; }
|
|
1440
1533
|
}
|
|
1441
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* @param {any} current
|
|
1537
|
+
* @param {import('./driver.js').Action} action
|
|
1538
|
+
* @returns {Promise<import('./driver.js').ActResult>}
|
|
1539
|
+
*/
|
|
1540
|
+
async function actOnPage(current, action) {
|
|
1541
|
+
const before = lastHash;
|
|
1542
|
+
const urlBefore = pageUrl(current);
|
|
1543
|
+
const tokenBefore = await documentToken(current);
|
|
1544
|
+
let typed = null;
|
|
1545
|
+
try {
|
|
1546
|
+
switch (action.kind) {
|
|
1547
|
+
case 'key':
|
|
1548
|
+
if (action.holdMs > 0 || (Array.isArray(action.keys) && action.keys.length > 0)) {
|
|
1549
|
+
// Held keys go down together and come up in reverse, even when
|
|
1550
|
+
// one is refused, so nothing stays held for the rest of the run.
|
|
1551
|
+
const keys = [action.key, ...(action.keys || [])].map(key => normalizeChord(key, editModifier));
|
|
1552
|
+
const down = [];
|
|
1553
|
+
try {
|
|
1554
|
+
for (const key of keys) {
|
|
1555
|
+
await current.keyboard.down(key);
|
|
1556
|
+
down.push(key);
|
|
1557
|
+
}
|
|
1558
|
+
await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
|
|
1559
|
+
} finally {
|
|
1560
|
+
for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
|
|
1561
|
+
}
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
1565
|
+
break;
|
|
1566
|
+
case 'type':
|
|
1567
|
+
typed = await typeText(current, action);
|
|
1568
|
+
if (action.submit) await current.keyboard.press('Enter');
|
|
1569
|
+
break;
|
|
1570
|
+
case 'click': {
|
|
1571
|
+
if (!action.ref && action.target && typeof action.target === 'object') {
|
|
1572
|
+
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
const resolved = await targetFor(current, action);
|
|
1576
|
+
if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
|
|
1577
|
+
else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case 'navigate': {
|
|
1581
|
+
const url = String(action.url);
|
|
1582
|
+
if (!onBuild(url)) {
|
|
1583
|
+
// A refusal by the runner, not a failure of the game or the
|
|
1584
|
+
// driver: ./tools.js answers it with plain text and the persona
|
|
1585
|
+
// plays on (an off-site address in a brief is common).
|
|
1586
|
+
recordOffSite(url, 'navigate');
|
|
1587
|
+
offSite[offSite.length - 1].claimed = true;
|
|
1588
|
+
return { ok: false, changed: false, refused: 'off_site', host: offSiteHost(url), error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
|
|
1589
|
+
}
|
|
1590
|
+
await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
case 'wait':
|
|
1594
|
+
await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
|
|
1595
|
+
break;
|
|
1596
|
+
default:
|
|
1597
|
+
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
1598
|
+
}
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
// Playwright colours its call log; the colour codes are noise here.
|
|
1601
|
+
const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
|
|
1602
|
+
// Two elements under one name: Playwright's strict mode. Answered
|
|
1603
|
+
// with the candidates and their refs, so the next try can work.
|
|
1604
|
+
if (/strict mode violation/i.test(text)) {
|
|
1605
|
+
const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
|
|
1606
|
+
const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
|
|
1607
|
+
return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
|
|
1608
|
+
}
|
|
1609
|
+
if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
|
|
1610
|
+
// A click the page kept intercepting is the game's: something sat on
|
|
1611
|
+
// top of the target and never went away. Named, never a driver error.
|
|
1612
|
+
const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
|
|
1613
|
+
const intercepted = interceptor ? interceptor[1] : null;
|
|
1614
|
+
if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
|
|
1615
|
+
return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
|
|
1616
|
+
}
|
|
1617
|
+
// A target the page shows as disabled, hidden or not editable is the
|
|
1618
|
+
// game refusing the action, which is what a player sees: evidence,
|
|
1619
|
+
// never a driver error. The resolved element is named so the report
|
|
1620
|
+
// step can cite it.
|
|
1621
|
+
const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
|
|
1622
|
+
if (refusal) {
|
|
1623
|
+
return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
|
|
1624
|
+
}
|
|
1625
|
+
// The browser or page closed under the runner: the runner's failure.
|
|
1626
|
+
if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
|
|
1627
|
+
// A timeout with the page unchanged while resolving a click or typing
|
|
1628
|
+
// target means the target was never found or never actionable: the
|
|
1629
|
+
// driver's failure, not the game's. Never for navigate: a game that
|
|
1630
|
+
// never loads is a real blocker.
|
|
1631
|
+
if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
|
|
1632
|
+
let unchanged = false;
|
|
1633
|
+
try {
|
|
1634
|
+
unchanged = (await currentHash(current)) === before;
|
|
1635
|
+
} catch {
|
|
1636
|
+
unchanged = false;
|
|
1637
|
+
}
|
|
1638
|
+
return { ok: false, changed: false, error: text, driverError: unchanged };
|
|
1639
|
+
}
|
|
1640
|
+
return { ok: false, changed: false, error: text };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const measure = async () => {
|
|
1644
|
+
const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
|
|
1645
|
+
// The same basis observe() hashes, so the two are comparable.
|
|
1646
|
+
// Without it a long screen is always "changed".
|
|
1647
|
+
facts.hashAfter = await currentHash(current);
|
|
1648
|
+
return computeChanged(facts);
|
|
1649
|
+
};
|
|
1650
|
+
let changed = false;
|
|
1651
|
+
try {
|
|
1652
|
+
changed = await measure();
|
|
1653
|
+
// A click or a submit can start a navigation that has not landed yet:
|
|
1654
|
+
// look once more after a short pause before calling it a no-op.
|
|
1655
|
+
if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
|
|
1656
|
+
await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
|
|
1657
|
+
changed = await measure();
|
|
1658
|
+
}
|
|
1659
|
+
} catch {
|
|
1660
|
+
changed = true;
|
|
1661
|
+
}
|
|
1662
|
+
return { ok: true, changed };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1442
1665
|
return {
|
|
1443
1666
|
name: 'playwright_web',
|
|
1444
1667
|
supportsRefs: true,
|
|
@@ -1490,22 +1713,62 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1490
1713
|
// One token per document: set again by every new document, kept by
|
|
1491
1714
|
// pushState and hash route changes, which do not run init scripts.
|
|
1492
1715
|
if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1716
|
+
// The page never leaves the build, recording or not. A top-level
|
|
1717
|
+
// navigation off it (a clicked link, a form, a script) is answered with
|
|
1718
|
+
// an empty 204, which a browser treats as "stay where you are": the
|
|
1719
|
+
// game keeps its state and nothing is fetched from the other site. An
|
|
1720
|
+
// abort would put an error page in its place instead. Only addresses
|
|
1721
|
+
// off the build are routed at all; subresources pass as before, and
|
|
1722
|
+
// an embedded frame may still load what it loads.
|
|
1723
|
+
buildTargets = [url];
|
|
1724
|
+
if (typeof context.route === 'function') {
|
|
1725
|
+
await context.route(candidate => !onBuild(String(candidate && candidate.href ? candidate.href : candidate)), route => {
|
|
1496
1726
|
const request = route.request();
|
|
1497
|
-
if (
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1727
|
+
if (!guardArmed || !request.isNavigationRequest()) return route.continue();
|
|
1728
|
+
const topLevel = isTopLevel(request);
|
|
1729
|
+
if (topLevel === false) return route.continue();
|
|
1730
|
+
// A new window's first request (no frame yet) is answered the same
|
|
1731
|
+
// way: the window never gets a document, so Playwright never even
|
|
1732
|
+
// surfaces it as a page.
|
|
1733
|
+
recordOffSite(request.url(), topLevel === null ? 'new window' : 'navigation');
|
|
1734
|
+
return route.fulfill({ status: 204, body: '' });
|
|
1501
1735
|
});
|
|
1502
1736
|
}
|
|
1503
1737
|
captureStartedAt = videoRequested ? now() : null;
|
|
1504
1738
|
page = await context.newPage();
|
|
1739
|
+
// A new window that did get a document (one on the build, or a blank
|
|
1740
|
+
// one a script means to point somewhere) is never played either: it is
|
|
1741
|
+
// closed at once and noted. (Context video may also briefly allocate a
|
|
1742
|
+
// file for it; finalization discards every non-selected file.)
|
|
1743
|
+
page.on('popup', popup => {
|
|
1744
|
+
let opened = null;
|
|
1745
|
+
try {
|
|
1746
|
+
opened = popup.url();
|
|
1747
|
+
} catch {
|
|
1748
|
+
opened = null;
|
|
1749
|
+
}
|
|
1750
|
+
recordOffSite(opened && opened !== 'about:blank' ? opened : 'about:blank', 'new window');
|
|
1751
|
+
if (!opened || opened === 'about:blank') offSite[offSite.length - 1].host = 'a new window';
|
|
1752
|
+
void popup.close().catch(() => {});
|
|
1753
|
+
});
|
|
1754
|
+
// The backstop: a top-level document that landed off the build anyway
|
|
1755
|
+
// (a server redirect after its first hop) is undone before anything
|
|
1756
|
+
// observes it. Error pages are the build's own failures, not a visit.
|
|
1757
|
+
page.on('framenavigated', frame => {
|
|
1758
|
+
try {
|
|
1759
|
+
if (!guardArmed || typeof page.mainFrame !== 'function' || frame !== page.mainFrame()) return;
|
|
1760
|
+
const landed = frame.url();
|
|
1761
|
+
if (onBuild(landed)) { lastBuildUrl = landed; return; }
|
|
1762
|
+
if (!/^https?:/i.test(landed)) return;
|
|
1763
|
+
recordOffSite(landed, 'redirect');
|
|
1764
|
+
const current = page;
|
|
1765
|
+
const previous = recovery;
|
|
1766
|
+
recovery = Promise.resolve(previous).then(() => recoverToBuild(current));
|
|
1767
|
+
} catch {
|
|
1768
|
+
// A frame that cannot be read is not a navigation worth undoing.
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1505
1771
|
if (videoRequested) {
|
|
1506
|
-
// Context video may briefly allocate a popup file; close it immediately
|
|
1507
|
-
// and discard all non-selected files during finalization.
|
|
1508
|
-
page.on('popup', popup => { void popup.close().catch(() => {}); });
|
|
1509
1772
|
page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
|
|
1510
1773
|
page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
|
|
1511
1774
|
captureTimer = setInterval(() => { void inspectCapture(); }, 250);
|
|
@@ -1556,6 +1819,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1556
1819
|
if (response && response.status() >= 400) {
|
|
1557
1820
|
throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
|
|
1558
1821
|
}
|
|
1822
|
+
// Where the first load landed is the build too (a hosted address that
|
|
1823
|
+
// redirects to its canonical one, http to https). From here on, leaving
|
|
1824
|
+
// is refused.
|
|
1825
|
+
try {
|
|
1826
|
+
const landed = page.url();
|
|
1827
|
+
if (/^https?:/i.test(landed) && !onBuild(landed)) buildTargets.push(landed);
|
|
1828
|
+
lastBuildUrl = /^https?:/i.test(landed) ? landed : url;
|
|
1829
|
+
} catch {
|
|
1830
|
+
lastBuildUrl = url;
|
|
1831
|
+
}
|
|
1832
|
+
guardArmed = true;
|
|
1559
1833
|
} catch (error) {
|
|
1560
1834
|
await stop({ reason: 'launch_failed' }).catch(() => {});
|
|
1561
1835
|
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
|
|
@@ -1566,6 +1840,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1566
1840
|
/** @returns {Promise<import('./driver.js').Observation>} */
|
|
1567
1841
|
async observe() {
|
|
1568
1842
|
const current = requirePage();
|
|
1843
|
+
await settled();
|
|
1569
1844
|
const started = Date.now();
|
|
1570
1845
|
step += 1;
|
|
1571
1846
|
const snap = await snapshotText();
|
|
@@ -1621,125 +1896,43 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1621
1896
|
*/
|
|
1622
1897
|
async act(action) {
|
|
1623
1898
|
const current = requirePage();
|
|
1624
|
-
|
|
1625
|
-
const
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
for (const key of keys) {
|
|
1638
|
-
await current.keyboard.down(key);
|
|
1639
|
-
down.push(key);
|
|
1640
|
-
}
|
|
1641
|
-
await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
|
|
1642
|
-
} finally {
|
|
1643
|
-
for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
|
|
1644
|
-
}
|
|
1645
|
-
break;
|
|
1646
|
-
}
|
|
1647
|
-
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
1648
|
-
break;
|
|
1649
|
-
case 'type':
|
|
1650
|
-
typed = await typeText(current, action);
|
|
1651
|
-
if (action.submit) await current.keyboard.press('Enter');
|
|
1652
|
-
break;
|
|
1653
|
-
case 'click': {
|
|
1654
|
-
if (!action.ref && action.target && typeof action.target === 'object') {
|
|
1655
|
-
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
1656
|
-
break;
|
|
1657
|
-
}
|
|
1658
|
-
const resolved = await targetFor(current, action);
|
|
1659
|
-
if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
|
|
1660
|
-
else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
|
|
1661
|
-
break;
|
|
1662
|
-
}
|
|
1663
|
-
case 'navigate': {
|
|
1664
|
-
const url = String(action.url);
|
|
1665
|
-
if (!isNavigationAllowed(url, target)) {
|
|
1666
|
-
return { ok: false, changed: false, error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
|
|
1667
|
-
}
|
|
1668
|
-
await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1669
|
-
break;
|
|
1670
|
-
}
|
|
1671
|
-
case 'wait':
|
|
1672
|
-
await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
|
|
1673
|
-
break;
|
|
1674
|
-
default:
|
|
1675
|
-
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
1676
|
-
}
|
|
1677
|
-
} catch (error) {
|
|
1678
|
-
// Playwright colours its call log; the colour codes are noise here.
|
|
1679
|
-
const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
|
|
1680
|
-
// Two elements under one name: Playwright's strict mode. Answered
|
|
1681
|
-
// with the candidates and their refs, so the next try can work.
|
|
1682
|
-
if (/strict mode violation/i.test(text)) {
|
|
1683
|
-
const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
|
|
1684
|
-
const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
|
|
1685
|
-
return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
|
|
1686
|
-
}
|
|
1687
|
-
if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
|
|
1688
|
-
// A click the page kept intercepting is the game's: something sat on
|
|
1689
|
-
// top of the target and never went away. Named, never a driver error.
|
|
1690
|
-
const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
|
|
1691
|
-
const intercepted = interceptor ? interceptor[1] : null;
|
|
1692
|
-
if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
|
|
1693
|
-
return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
|
|
1694
|
-
}
|
|
1695
|
-
// A target the page shows as disabled, hidden or not editable is the
|
|
1696
|
-
// game refusing the action, which is what a player sees: evidence,
|
|
1697
|
-
// never a driver error. The resolved element is named so the report
|
|
1698
|
-
// step can cite it.
|
|
1699
|
-
const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
|
|
1700
|
-
if (refusal) {
|
|
1701
|
-
return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
|
|
1702
|
-
}
|
|
1703
|
-
// The browser or page closed under the runner: the runner's failure.
|
|
1704
|
-
if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
|
|
1705
|
-
// A timeout with the page unchanged while resolving a click or typing
|
|
1706
|
-
// target means the target was never found or never actionable: the
|
|
1707
|
-
// driver's failure, not the game's. Never for navigate: a game that
|
|
1708
|
-
// never loads is a real blocker.
|
|
1709
|
-
if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
|
|
1710
|
-
let unchanged = false;
|
|
1711
|
-
try {
|
|
1712
|
-
unchanged = (await currentHash(current)) === before;
|
|
1713
|
-
} catch {
|
|
1714
|
-
unchanged = false;
|
|
1715
|
-
}
|
|
1716
|
-
return { ok: false, changed: false, error: text, driverError: unchanged };
|
|
1717
|
-
}
|
|
1718
|
-
return { ok: false, changed: false, error: text };
|
|
1899
|
+
await settled();
|
|
1900
|
+
const attemptsBefore = offSiteCount;
|
|
1901
|
+
const outcome = await actOnPage(current, action);
|
|
1902
|
+
if (outcome.refused) return outcome;
|
|
1903
|
+
// A click, a key, a submit or a script that tried to leave the build:
|
|
1904
|
+
// the page stayed (or was brought back), and the persona is told the
|
|
1905
|
+
// same as for a refused `navigate`. A step that changed nothing.
|
|
1906
|
+
await settled();
|
|
1907
|
+
if (offSiteCount > attemptsBefore) {
|
|
1908
|
+
const fresh = offSite.filter(attempt => !attempt.claimed).slice(-(offSiteCount - attemptsBefore));
|
|
1909
|
+
for (const attempt of fresh) attempt.claimed = true;
|
|
1910
|
+
const host = fresh.length > 0 ? fresh[fresh.length - 1].host : 'another site';
|
|
1911
|
+
return { ok: false, changed: false, refused: 'off_site', host, error: `the page tried to leave the build for ${host}; the runner kept it on the build` };
|
|
1719
1912
|
}
|
|
1913
|
+
return outcome;
|
|
1914
|
+
},
|
|
1720
1915
|
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1916
|
+
/**
|
|
1917
|
+
* Every attempt to leave the build since the last call, claimed by an
|
|
1918
|
+
* action or not (a timer redirect between actions has no action), for the
|
|
1919
|
+
* run's notes. Hosts and addresses without their query strings only.
|
|
1920
|
+
* @returns {Array<{host: string, url: string, via: string}>}
|
|
1921
|
+
*/
|
|
1922
|
+
offSiteAttempts() {
|
|
1923
|
+
return offSite.splice(0, offSite.length).map(({ host, url: address, via }) => ({ host, url: address, via }));
|
|
1924
|
+
},
|
|
1925
|
+
|
|
1926
|
+
/** How many pages the browser has open: one, whatever the game opened. */
|
|
1927
|
+
openPages() {
|
|
1729
1928
|
try {
|
|
1730
|
-
|
|
1731
|
-
// A click or a submit can start a navigation that has not landed yet:
|
|
1732
|
-
// look once more after a short pause before calling it a no-op.
|
|
1733
|
-
if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
|
|
1734
|
-
await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
|
|
1735
|
-
changed = await measure();
|
|
1736
|
-
}
|
|
1929
|
+
return context && typeof context.pages === 'function' ? context.pages().length : (page ? 1 : 0);
|
|
1737
1930
|
} catch {
|
|
1738
|
-
|
|
1931
|
+
return page ? 1 : 0;
|
|
1739
1932
|
}
|
|
1740
|
-
return { ok: true, changed };
|
|
1741
1933
|
},
|
|
1742
1934
|
|
|
1935
|
+
|
|
1743
1936
|
/**
|
|
1744
1937
|
* @param {string} name
|
|
1745
1938
|
* @returns {Promise<{path: string, relativePath: string}>}
|
package/src/run/model.js
CHANGED
|
@@ -170,7 +170,24 @@ export function toProxyError(error) {
|
|
|
170
170
|
if (key !== 'type' && key !== 'message') figures[key] = value;
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
-
return new ProxyError({ status, code, message, retryAfter, figures });
|
|
173
|
+
return new ProxyError({ status, code, message: withProviderReason(message, figures), retryAfter, figures });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The provider's own reason, when the proxy passed one on (a 400
|
|
178
|
+
* `upstream_rejected` carries `provider_type` and a cleaned
|
|
179
|
+
* `provider_message`), added to the message the developer is shown. Without
|
|
180
|
+
* it a refused body reads only "The model provider rejected this request."
|
|
181
|
+
*
|
|
182
|
+
* @param {string} message
|
|
183
|
+
* @param {Object} figures
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
export function withProviderReason(message, figures = {}) {
|
|
187
|
+
const type = typeof figures.provider_type === 'string' ? figures.provider_type : '';
|
|
188
|
+
const reason = typeof figures.provider_message === 'string' ? figures.provider_message : '';
|
|
189
|
+
if (!type && !reason) return message;
|
|
190
|
+
return `${message} Provider said: ${[type, reason].filter(Boolean).join(': ')}`;
|
|
174
191
|
}
|
|
175
192
|
|
|
176
193
|
/**
|
package/src/run/openPr.js
CHANGED
|
@@ -85,7 +85,14 @@ export async function openPullRequest({ repoRoot, branch, paths, title: rawTitle
|
|
|
85
85
|
...paths.map(file => ` ${plainTitle(file)}`)
|
|
86
86
|
].join('\n');
|
|
87
87
|
// `yes: false` on purpose: --yes agreed to the price, not to publishing.
|
|
88
|
-
|
|
88
|
+
let go;
|
|
89
|
+
try { go = await confirm(question, { yes: false }); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
// --yes cannot agree to publishing, so with no terminal the answer is a
|
|
92
|
+
// plain refusal, never a piped "y".
|
|
93
|
+
if (error?.code === 'no_terminal') return refuse('--open-pr asks before it pushes, and there is no terminal to ask in.');
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
89
96
|
if (!go) return refuse('Not confirmed.');
|
|
90
97
|
|
|
91
98
|
const added = await call('git', ['add', '--', ...paths]);
|
package/src/run/personaLoop.js
CHANGED
|
@@ -2,7 +2,7 @@ import { rewriteMarkdown } from './dropped.js';
|
|
|
2
2
|
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
5
|
+
import { prepareMessages, repairLastTurn, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
6
6
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
8
|
import { RUN_FILES } from './paths.js';
|
|
@@ -76,6 +76,25 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
|
|
|
76
76
|
/** What a capped finding's description gains, so the reader knows why. */
|
|
77
77
|
export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
|
|
78
78
|
|
|
79
|
+
/** How each way of leaving the build reads in a note. */
|
|
80
|
+
const OFF_SITE_VIA = Object.freeze({
|
|
81
|
+
navigation: 'a link, form or script on the page',
|
|
82
|
+
'new window': 'a new window',
|
|
83
|
+
redirect: 'a redirect',
|
|
84
|
+
navigate: 'the persona\'s navigate'
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The network note for one attempt to leave the build.
|
|
89
|
+
*
|
|
90
|
+
* @param {{host?: string, via?: string}} attempt
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function offSiteNoteText(attempt) {
|
|
94
|
+
const via = OFF_SITE_VIA[attempt && attempt.via] || 'the page';
|
|
95
|
+
return `off-site: ${via} tried to leave the build for ${(attempt && attempt.host) || 'another site'}; the runner kept the page on the build.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
/** A persona note kind as a report finding category. */
|
|
80
99
|
// `waiting` has no category on purpose: a "waiting for X to finish" note is
|
|
81
100
|
// context, and nothing turns it into a finding.
|
|
@@ -530,23 +549,39 @@ async function playLoop({
|
|
|
530
549
|
turns += 1;
|
|
531
550
|
let answer;
|
|
532
551
|
const askedAt = Date.now();
|
|
552
|
+
const askAct = () => callWithImageRetry({
|
|
553
|
+
client,
|
|
554
|
+
jobId,
|
|
555
|
+
runId,
|
|
556
|
+
step: STEPS.personaAct,
|
|
557
|
+
system,
|
|
558
|
+
messages,
|
|
559
|
+
tools: playTools,
|
|
560
|
+
maxTokens: ACT_MAX_TOKENS,
|
|
561
|
+
escalate: shouldEscalate(persona),
|
|
562
|
+
imageBudget,
|
|
563
|
+
keepObservations,
|
|
564
|
+
onImageBudget: next => {
|
|
565
|
+
imageBudget = next;
|
|
566
|
+
}
|
|
567
|
+
});
|
|
533
568
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
569
|
+
try {
|
|
570
|
+
answer = await askAct();
|
|
571
|
+
} catch (error) {
|
|
572
|
+
// The provider refused the body. Every tool_result is already made
|
|
573
|
+
// valid before it is sent (./context.js), so this is a shape nobody
|
|
574
|
+
// has seen yet: the newest turn is rewritten as plain text and asked
|
|
575
|
+
// once more. A second refusal ends play, and the run still reports
|
|
576
|
+
// from its step log (runPersona's error path), because a run that
|
|
577
|
+
// played never comes back empty.
|
|
578
|
+
if (!(error instanceof ProxyError) || error.code !== 'upstream_rejected') throw error;
|
|
579
|
+
log(`the model provider rejected this turn (${error.message}); retrying once with the last turn as plain text.`);
|
|
580
|
+
transcript.write({ kind: 'provider_rejected', retry: true, code: error.code, provider_type: error.figures.provider_type || null, provider_message: error.figures.provider_message || null });
|
|
581
|
+
const repaired = repairLastTurn(messages);
|
|
582
|
+
messages.splice(0, messages.length, ...repaired);
|
|
583
|
+
answer = await askAct();
|
|
584
|
+
}
|
|
550
585
|
} catch (error) {
|
|
551
586
|
if (error instanceof ProxyError && error.isBudget) {
|
|
552
587
|
budgetExceeded = true;
|
|
@@ -663,6 +698,7 @@ async function playLoop({
|
|
|
663
698
|
ok: !executed.error,
|
|
664
699
|
error: executed.error,
|
|
665
700
|
changed: executed.changed,
|
|
701
|
+
...(executed.refused ? { refused: executed.refused } : {}),
|
|
666
702
|
actions
|
|
667
703
|
};
|
|
668
704
|
transcript.write(actionLine);
|
|
@@ -691,6 +727,13 @@ async function playLoop({
|
|
|
691
727
|
// declared wedged for doing the right thing.
|
|
692
728
|
if (ACTION_TOOLS.includes(toolUse.name)) unchanged = executed.changed ? 0 : unchanged + 1;
|
|
693
729
|
}
|
|
730
|
+
if (executed.refused) {
|
|
731
|
+
// A refused off-site navigation is a step that changed nothing: it
|
|
732
|
+
// counts toward the stuck limit like any other, and play goes on.
|
|
733
|
+
unchanged += 1;
|
|
734
|
+
recentActions.push(`${toolUse.name}: refused, off-site`);
|
|
735
|
+
if (recentActions.length > 10) recentActions.shift();
|
|
736
|
+
}
|
|
694
737
|
onProgress({ phase: 'playing', persona: persona.slug, actions, maxActions, tool: toolUse.name });
|
|
695
738
|
}
|
|
696
739
|
|
|
@@ -732,6 +775,24 @@ async function playLoop({
|
|
|
732
775
|
asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
|
|
733
776
|
}
|
|
734
777
|
|
|
778
|
+
// Every attempt to leave the build this turn (a clicked link, a form, a
|
|
779
|
+
// new window, a script or a server redirect, or a refused navigate) is a
|
|
780
|
+
// network note: the runner kept the page on the build, and the report
|
|
781
|
+
// step reads it as context. A persona told "Refused" already knows; a
|
|
782
|
+
// timer redirect between actions is only ever seen here.
|
|
783
|
+
const leaving = typeof driver.offSiteAttempts === 'function' ? driver.offSiteAttempts() : [];
|
|
784
|
+
for (const attempt of leaving) {
|
|
785
|
+
const note = {
|
|
786
|
+
kind: 'network',
|
|
787
|
+
text: offSiteNoteText(attempt),
|
|
788
|
+
automatic: true,
|
|
789
|
+
off_site: true,
|
|
790
|
+
url: String(attempt.url || attempt.host || '').slice(0, 200)
|
|
791
|
+
};
|
|
792
|
+
noted(note);
|
|
793
|
+
transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
|
|
794
|
+
}
|
|
795
|
+
|
|
735
796
|
const wedged = unchanged >= STUCK_LIMIT;
|
|
736
797
|
if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
|
|
737
798
|
transcript.write({ kind: 'stuck', unchanged, fatal: false });
|
package/src/run/tools.js
CHANGED
|
@@ -335,6 +335,19 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
335
335
|
: action.ref && staleRefs
|
|
336
336
|
? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
|
|
337
337
|
: await driver.act(action);
|
|
338
|
+
// The runner refused to leave the build. The persona is told so in plain
|
|
339
|
+
// text and plays on: it is a step that changed nothing, never an error and
|
|
340
|
+
// never a run failure. No observation either, because nothing moved.
|
|
341
|
+
if (outcome.refused === 'off_site') {
|
|
342
|
+
return {
|
|
343
|
+
result: toolResult(toolUse.id, offSiteRefusalText(outcome.host)),
|
|
344
|
+
observation: null,
|
|
345
|
+
spentAction: ACTION_TOOLS.includes(toolUse.name),
|
|
346
|
+
changed: false,
|
|
347
|
+
error: null,
|
|
348
|
+
refused: 'off_site'
|
|
349
|
+
};
|
|
350
|
+
}
|
|
338
351
|
const observation = await driver.observe();
|
|
339
352
|
const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
|
|
340
353
|
const prefix = outcome.ok
|
|
@@ -350,11 +363,25 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
350
363
|
};
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
/**
|
|
367
|
+
* What the persona is told when the runner refuses to leave the build.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} [host]
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
export function offSiteRefusalText(host) {
|
|
373
|
+
return `Refused: that link leaves the build under test (${host || 'another site'}). Stay on this page and use its controls.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
353
376
|
/**
|
|
354
377
|
* A tool_result whose content is the new observation, with the screenshot
|
|
355
378
|
* attached as an image when the driver decided the text was too thin to
|
|
356
379
|
* play from. Images are pruned from history by the caller: the act step
|
|
357
|
-
* accepts a very small number of image blocks per request.
|
|
380
|
+
* accepts a very small number of image blocks per request. When the screenshot
|
|
381
|
+
* rides along, a failed action is NOT flagged `is_error`: the Messages API
|
|
382
|
+
* refuses an `is_error` result that carries an image, and the image is the
|
|
383
|
+
* persona's only view of a canvas game. The "That did not work" prefix
|
|
384
|
+
* already says what the flag would.
|
|
358
385
|
*
|
|
359
386
|
* @param {string} id
|
|
360
387
|
* @param {Object} observation
|
|
@@ -382,7 +409,8 @@ export function observationResult(id, observation, prefix = '', isError = false)
|
|
|
382
409
|
}
|
|
383
410
|
});
|
|
384
411
|
}
|
|
385
|
-
|
|
412
|
+
const withImage = content.length > 1;
|
|
413
|
+
return { type: 'tool_result', tool_use_id: id, content, ...(isError && !withImage ? { is_error: true } : {}) };
|
|
386
414
|
}
|
|
387
415
|
|
|
388
416
|
/**
|
|
@@ -429,4 +457,4 @@ function message(error) {
|
|
|
429
457
|
return String(error && error.message ? error.message : error);
|
|
430
458
|
}
|
|
431
459
|
|
|
432
|
-
export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult };
|
|
460
|
+
export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult, offSiteRefusalText };
|
package/src/ui/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stableStringify } from '../fsutil.js';
|
|
3
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Terminal output, in one place.
|
|
@@ -183,22 +184,36 @@ export function spinner(label) {
|
|
|
183
184
|
};
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
/** The one line printed when a question needs a terminal and there is none. */
|
|
188
|
+
export const NO_TERMINAL_MESSAGE = 'No terminal to confirm in: pass --yes to accept the shown price and hold.';
|
|
189
|
+
|
|
190
|
+
function noTerminal(message = NO_TERMINAL_MESSAGE) {
|
|
191
|
+
const error = new CliError(message, ExitCode.FAILURE);
|
|
192
|
+
error.code = 'no_terminal';
|
|
193
|
+
return error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** True for the error confirm() throws when there is no terminal to ask in. */
|
|
197
|
+
export const isNoTerminal = error => error instanceof CliError && error.code === 'no_terminal';
|
|
198
|
+
|
|
186
199
|
/**
|
|
187
200
|
* Ask a yes or no question.
|
|
188
201
|
*
|
|
189
|
-
* `--yes` skips it
|
|
190
|
-
* answer would hang a CI job forever,
|
|
191
|
-
*
|
|
192
|
-
*
|
|
202
|
+
* `--yes` skips it. A non interactive stdin is an error, not an answer: a
|
|
203
|
+
* prompt nobody can answer would hang a CI job forever, and piped text such as
|
|
204
|
+
* `echo y |` is never taken as consent, because the question is usually
|
|
205
|
+
* "shall I spend your money". Quietly declining instead left people wondering
|
|
206
|
+
* why nothing happened, so the error says what to do.
|
|
193
207
|
*
|
|
194
208
|
* @param {string} question
|
|
195
|
-
* @param {{yes?: boolean, input?: object, output?: object}} [options]
|
|
209
|
+
* @param {{yes?: boolean, input?: object, output?: object, noTerminalMessage?: string}} [options]
|
|
210
|
+
* noTerminalMessage replaces the line for a question that is not about money.
|
|
196
211
|
* @returns {Promise<boolean>}
|
|
197
212
|
*/
|
|
198
213
|
export async function confirm(question, options = {}) {
|
|
199
214
|
if (options.yes) return true;
|
|
200
215
|
const input = options.input || process.stdin;
|
|
201
|
-
if (!input.isTTY && !options.input)
|
|
216
|
+
if (!input.isTTY && !options.input) throw noTerminal(options.noTerminalMessage);
|
|
202
217
|
|
|
203
218
|
const rl = createInterface({ input, output: options.output || stream });
|
|
204
219
|
try {
|