ravensight-playtest 0.2.9 → 0.3.1
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 +105 -12
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +73 -6
- package/src/run/aggregate.js +103 -36
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +619 -43
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +603 -45
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +49 -12
- package/src/run/usage.js +19 -1
package/src/run/regenerate.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function readTranscript(runDirectory) {
|
|
|
47
47
|
try {
|
|
48
48
|
text = await readFile(path.join(runDirectory, RUN_FILES.transcript), 'utf8');
|
|
49
49
|
} catch {
|
|
50
|
-
return { header: null, notes: [], screenshots: [], observations: 0, actions: 0 };
|
|
50
|
+
return { header: null, notes: [], screenshots: [], observations: 0, actions: 0, firstT: null, lastT: null };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
let header = null;
|
|
@@ -55,6 +55,12 @@ export async function readTranscript(runDirectory) {
|
|
|
55
55
|
const screenshots = [];
|
|
56
56
|
let observations = 0;
|
|
57
57
|
let actions = 0;
|
|
58
|
+
// Notes written on the tool call the last `action` line closed, so a
|
|
59
|
+
// `driver_error` after it can mark them as the live run did.
|
|
60
|
+
let callNotes = [];
|
|
61
|
+
let openNotes = [];
|
|
62
|
+
let firstT = null;
|
|
63
|
+
let lastT = null;
|
|
58
64
|
for (const line of text.split('\n')) {
|
|
59
65
|
if (!line.trim()) continue;
|
|
60
66
|
let entry;
|
|
@@ -65,13 +71,27 @@ export async function readTranscript(runDirectory) {
|
|
|
65
71
|
// it rather than refusing to read the whole transcript.
|
|
66
72
|
continue;
|
|
67
73
|
}
|
|
74
|
+
if (Number.isFinite(Number(entry.t))) {
|
|
75
|
+
if (firstT === null) firstT = Number(entry.t);
|
|
76
|
+
lastT = Number(entry.t);
|
|
77
|
+
}
|
|
68
78
|
if (entry.kind === 'header') header = entry;
|
|
69
|
-
else if (entry.kind === 'note' && entry.note)
|
|
79
|
+
else if (entry.kind === 'note' && entry.note) {
|
|
80
|
+
const note = { ...entry.note };
|
|
81
|
+
notes.push(note);
|
|
82
|
+
if (entry.tool) openNotes.push(note);
|
|
83
|
+
} else if (entry.kind === 'driver_error') {
|
|
84
|
+
for (const note of callNotes) note.driver_error = true;
|
|
85
|
+
}
|
|
70
86
|
else if (entry.kind === 'screenshot' && entry.path) screenshots.push({ relativePath: entry.path, reason: entry.reason || '' });
|
|
71
87
|
else if (entry.kind === 'observation') observations += 1;
|
|
72
|
-
else if (entry.kind === 'action'
|
|
88
|
+
else if (entry.kind === 'action') {
|
|
89
|
+
callNotes = openNotes;
|
|
90
|
+
openNotes = [];
|
|
91
|
+
if (Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
|
|
92
|
+
}
|
|
73
93
|
}
|
|
74
|
-
return { header, notes, screenshots, observations, actions };
|
|
94
|
+
return { header, notes, screenshots, observations, actions, firstT, lastT };
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
/**
|
package/src/run/resume.js
CHANGED
|
@@ -244,7 +244,13 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
244
244
|
actions_taken: report.actions_taken,
|
|
245
245
|
quit_reason: 'error',
|
|
246
246
|
report,
|
|
247
|
-
report_md: markdown
|
|
247
|
+
report_md: markdown,
|
|
248
|
+
// The transcript's own span: its first and last timestamps. No model
|
|
249
|
+
// time is known for a run rebuilt after the process died.
|
|
250
|
+
duration: {
|
|
251
|
+
wall_seconds: transcript.firstT !== null && transcript.lastT !== null ? Number(((transcript.lastT - transcript.firstT) / 1000).toFixed(3)) : null,
|
|
252
|
+
model_seconds: null
|
|
253
|
+
}
|
|
248
254
|
}
|
|
249
255
|
});
|
|
250
256
|
if (answer && answer.dropped_findings > 0) {
|
package/src/run/tools.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
|
|
2
|
-
import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
|
|
2
|
+
import { OBSERVATION_MAX_CHARS, STALE_REF_MESSAGE, truncateObservation } from './drivers/driver.js';
|
|
3
|
+
|
|
4
|
+
export { STALE_REF_MESSAGE };
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* The client tool set for `persona_playtest.act`.
|
|
@@ -23,8 +25,12 @@ import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js'
|
|
|
23
25
|
* `snapshot`, `screenshot` and `wait` do not.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Note kinds, from spec 03's `log_note`, plus `waiting`: the persona is
|
|
30
|
+
* waiting for something to finish (a load, an animation, a turn). A waiting
|
|
31
|
+
* note is context for the report and never becomes a finding.
|
|
32
|
+
*/
|
|
33
|
+
export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction', 'waiting']);
|
|
28
34
|
|
|
29
35
|
/** Severities, from the report schema's finding severity enum. */
|
|
30
36
|
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
@@ -52,7 +58,7 @@ const note = {
|
|
|
52
58
|
additionalProperties: false,
|
|
53
59
|
required: ['kind', 'text'],
|
|
54
60
|
properties: {
|
|
55
|
-
kind: { type: 'string', enum: [...NOTE_KINDS] },
|
|
61
|
+
kind: { type: 'string', enum: [...NOTE_KINDS], description: 'waiting means you are waiting for something to finish; it is recorded as context and never reported as a finding.' },
|
|
56
62
|
text: { type: 'string' },
|
|
57
63
|
severity: { type: 'string', enum: [...SEVERITIES] }
|
|
58
64
|
}
|
|
@@ -62,7 +68,7 @@ const note = {
|
|
|
62
68
|
export const PLAY_TOOLS = Object.freeze([
|
|
63
69
|
{
|
|
64
70
|
name: 'snapshot',
|
|
65
|
-
description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state.',
|
|
71
|
+
description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state. The result also repeats this run\'s identity (the email and display name to use whenever a form asks for one).',
|
|
66
72
|
input_schema: {
|
|
67
73
|
type: 'object',
|
|
68
74
|
additionalProperties: false,
|
|
@@ -71,12 +77,13 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
71
77
|
},
|
|
72
78
|
{
|
|
73
79
|
name: 'click',
|
|
74
|
-
description: 'Click one thing. Spends an action.
|
|
80
|
+
description: 'Click one thing. Spends an action. On playwright_web, use the ref the screen shows next to the element, such as e12: it names exactly one element, even when two share a name. Otherwise name the target: on playwright_web an accessibility node such as button "Start" or a visible text fragment; on godot_driver a node path from the scene tree you were shown. Targets are refs or visible text, never locator code such as getByRole(...) or page.locator(...). Avoid x and y coordinates on playwright_web: they are blind to the page, and are only for a canvas with no refs, taken from the @(x,y) the screen shows or from the screenshot.',
|
|
75
81
|
input_schema: {
|
|
76
82
|
type: 'object',
|
|
77
83
|
additionalProperties: false,
|
|
78
84
|
properties: {
|
|
79
|
-
|
|
85
|
+
ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
|
|
86
|
+
target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
80
87
|
x: { type: 'number' },
|
|
81
88
|
y: { type: 'number' },
|
|
82
89
|
thought_in_character: thought,
|
|
@@ -95,7 +102,8 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
95
102
|
text: { type: 'string' },
|
|
96
103
|
submit: { type: 'boolean' },
|
|
97
104
|
replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
|
|
98
|
-
|
|
105
|
+
ref: { type: 'string', description: 'playwright_web only, the preferred way to name the field: its ref, such as e12.' },
|
|
106
|
+
target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it or a ref, the focused field.' },
|
|
99
107
|
thought_in_character: thought,
|
|
100
108
|
note
|
|
101
109
|
}
|
|
@@ -186,6 +194,7 @@ export function actionFor(toolUse) {
|
|
|
186
194
|
const input = toolUse.input || {};
|
|
187
195
|
switch (toolUse.name) {
|
|
188
196
|
case 'click':
|
|
197
|
+
if (typeof input.ref === 'string' && input.ref.trim()) return { kind: 'click', ref: input.ref.trim() };
|
|
189
198
|
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
190
199
|
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
191
200
|
: { kind: 'click', target: String(input.target || '') };
|
|
@@ -193,6 +202,7 @@ export function actionFor(toolUse) {
|
|
|
193
202
|
const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
194
203
|
if (input.replace === true) action.replace = true;
|
|
195
204
|
if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
|
|
205
|
+
if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
|
|
196
206
|
return action;
|
|
197
207
|
}
|
|
198
208
|
case 'key':
|
|
@@ -220,9 +230,11 @@ export function actionFor(toolUse) {
|
|
|
220
230
|
* @param {Object} args.toolUse
|
|
221
231
|
* @param {(note: Object) => void} [args.onNote]
|
|
222
232
|
* @param {(shot: Object) => void} [args.onScreenshot]
|
|
233
|
+
* @param {boolean} [args.staleRefs] an earlier action in this turn changed the
|
|
234
|
+
* screen, so a ref in this call was read off a screen that is gone
|
|
223
235
|
* @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
|
|
224
236
|
*/
|
|
225
|
-
export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
237
|
+
export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false, identity = null }) {
|
|
226
238
|
const input = toolUse.input || {};
|
|
227
239
|
if (toolUse.malformed) {
|
|
228
240
|
return {
|
|
@@ -260,8 +272,13 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
260
272
|
|
|
261
273
|
if (toolUse.name === 'snapshot') {
|
|
262
274
|
const observation = await driver.observe();
|
|
275
|
+
const result = observationResult(toolUse.id, observation);
|
|
276
|
+
// The run's identity rides on the one tool a persona calls to look
|
|
277
|
+
// around: the act step's tool allowlist has no room for a tool of its own.
|
|
278
|
+
const line = identityLine(identity);
|
|
279
|
+
if (line) result.content.push({ type: 'text', text: line });
|
|
263
280
|
return {
|
|
264
|
-
result
|
|
281
|
+
result,
|
|
265
282
|
observation,
|
|
266
283
|
spentAction: false,
|
|
267
284
|
changed: false,
|
|
@@ -280,7 +297,15 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
280
297
|
};
|
|
281
298
|
}
|
|
282
299
|
|
|
283
|
-
|
|
300
|
+
// A ref is a playwright_web target. Every other driver is told so by name,
|
|
301
|
+
// before anything reaches the game.
|
|
302
|
+
// A ref in a later tool call of a turn whose earlier call already changed
|
|
303
|
+
// the screen was read off a screen that is gone.
|
|
304
|
+
const outcome = action.ref && !driver.supportsRefs
|
|
305
|
+
? { 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
|
+
: action.ref && staleRefs
|
|
307
|
+
? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
|
|
308
|
+
: await driver.act(action);
|
|
284
309
|
const observation = await driver.observe();
|
|
285
310
|
const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
|
|
286
311
|
const prefix = outcome.ok
|
|
@@ -291,7 +316,8 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
291
316
|
observation,
|
|
292
317
|
spentAction,
|
|
293
318
|
changed: Boolean(outcome.changed),
|
|
294
|
-
error: outcome.ok ? null : String(outcome.error || 'act failed')
|
|
319
|
+
error: outcome.ok ? null : String(outcome.error || 'act failed'),
|
|
320
|
+
driverError: !outcome.ok && Boolean(outcome.driverError)
|
|
295
321
|
};
|
|
296
322
|
}
|
|
297
323
|
|
|
@@ -330,6 +356,17 @@ export function observationResult(id, observation, prefix = '', isError = false)
|
|
|
330
356
|
return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
|
|
331
357
|
}
|
|
332
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The run's identity as one line of a snapshot result, or '' with none.
|
|
361
|
+
*
|
|
362
|
+
* @param {{email?: string, displayName?: string}|null} identity
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
export function identityLine(identity) {
|
|
366
|
+
if (!identity || !identity.email) return '';
|
|
367
|
+
return `run identity: email ${identity.email}, display name ${identity.displayName}. Use these whenever a form asks for an email or a name.`;
|
|
368
|
+
}
|
|
369
|
+
|
|
333
370
|
/**
|
|
334
371
|
* @param {string} id
|
|
335
372
|
* @param {string} text
|
package/src/run/usage.js
CHANGED
|
@@ -29,6 +29,7 @@ export function createUsageLedger() {
|
|
|
29
29
|
const byModel = new Map();
|
|
30
30
|
const started = Date.now();
|
|
31
31
|
let calls = 0;
|
|
32
|
+
let modelMs = 0;
|
|
32
33
|
let serverTotalUsd = null;
|
|
33
34
|
let serverCalls = null;
|
|
34
35
|
|
|
@@ -36,8 +37,10 @@ export function createUsageLedger() {
|
|
|
36
37
|
/**
|
|
37
38
|
* @param {{model: string|null, usage: Object}} turn
|
|
38
39
|
*/
|
|
39
|
-
record({ model, usage }) {
|
|
40
|
+
record({ model, usage, ms }) {
|
|
40
41
|
calls += 1;
|
|
42
|
+
// Time spent waiting on the model, when the caller measured it.
|
|
43
|
+
if (Number.isFinite(Number(ms)) && Number(ms) > 0) modelMs += Number(ms);
|
|
41
44
|
const key = model || 'unknown';
|
|
42
45
|
const entry = byModel.get(key) || { model: key, input_tokens: 0, cache_read_tokens: 0, output_tokens: 0, usd: 0 };
|
|
43
46
|
// The report schema has no cache-creation field, so creation tokens
|
|
@@ -64,6 +67,20 @@ export function createUsageLedger() {
|
|
|
64
67
|
return calls;
|
|
65
68
|
},
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Wall time since the ledger was made, and time spent waiting on model
|
|
72
|
+
* calls, in seconds. What `complete` sends as `duration`, so the run card
|
|
73
|
+
* does not read 0s for a run that played for minutes.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{wall_seconds: number, model_seconds: number}}
|
|
76
|
+
*/
|
|
77
|
+
duration() {
|
|
78
|
+
return {
|
|
79
|
+
wall_seconds: Number(((Date.now() - started) / 1000).toFixed(3)),
|
|
80
|
+
model_seconds: Number((modelMs / 1000).toFixed(3))
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
85
|
* The `usage` object as the report schema wants it.
|
|
69
86
|
*
|
|
@@ -87,6 +104,7 @@ export function createUsageLedger() {
|
|
|
87
104
|
const report = this.toReportUsage();
|
|
88
105
|
return {
|
|
89
106
|
...report,
|
|
107
|
+
model_seconds: Number((modelMs / 1000).toFixed(3)),
|
|
90
108
|
local_model_calls: calls,
|
|
91
109
|
server_model_calls: serverCalls,
|
|
92
110
|
usd_source: serverTotalUsd === null ? 'unavailable' : 'server',
|