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.
@@ -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) notes.push(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' && Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
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
- /** Note kinds, from spec 03's `log_note`. */
27
- export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction']);
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. Either name the target or give x and y pixel coordinates. What a named target means depends on the driver: on playwright_web it is an accessibility node such as button "Start", a visible text fragment, or a CSS selector; on godot_driver it is a node path from the scene tree you were shown.',
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
- target: { type: 'string', description: 'web: button "Start", Start Game, or #start-button. Godot: a node path such as /root/Main/UI/StartButton.' },
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
- 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, the focused field.' },
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: observationResult(toolUse.id, observation),
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
- const outcome = await driver.act(action);
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',