ravensight-playtest 0.2.3 → 0.2.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 +89 -6
- package/docs/ADAPTERS.md +77 -0
- package/package.json +2 -1
- package/src/cli.js +12 -6
- package/src/commands/check.js +81 -0
- package/src/commands/fix.js +8 -4
- package/src/commands/init.js +29 -4
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +81 -26
- package/src/run/actionSelector.js +47 -0
- package/src/run/aggregate.js +10 -5
- package/src/run/args.js +19 -3
- package/src/run/context.js +12 -1
- package/src/run/drivers/driver.js +6 -3
- package/src/run/drivers/index.js +6 -6
- package/src/run/drivers/stdio.js +111 -0
- package/src/run/drivers/web.js +153 -7
- package/src/run/drivers/webdriver.js +67 -0
- package/src/run/fixLoop.js +52 -3
- package/src/run/personaLoop.js +204 -16
- package/src/run/resume.js +1 -0
- package/src/run/tools.js +19 -3
- package/src/states.js +4 -1
- package/src/ui/index.js +28 -0
package/src/run/personaLoop.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
1
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
4
5
|
import { observationHash } from './drivers/driver.js';
|
|
6
|
+
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
5
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
6
8
|
import { RUN_FILES } from './paths.js';
|
|
7
9
|
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
8
10
|
import { assertNoSecrets } from './secretScan.js';
|
|
9
|
-
import { ACTION_TOOLS, PLAY_TOOLS, REPORT_TOOLS, executeTool } from './tools.js';
|
|
11
|
+
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
10
12
|
import { createTranscript } from './transcript.js';
|
|
11
13
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
12
14
|
|
|
@@ -31,11 +33,30 @@ import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
|
31
33
|
* up, so the report (which is the product) gets written while there is
|
|
32
34
|
* still budget to write it with.
|
|
33
35
|
* - A 402 means the money is gone. Nothing further is asked of the model,
|
|
34
|
-
* including the report: a partial `report.md`
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
36
|
+
* including the report: a partial `report.md` and `report.json` are
|
|
37
|
+
* written locally from the notes (`partial: true`, each note an
|
|
38
|
+
* unreviewed finding), `complete` is sent with `state: budget_exceeded`
|
|
39
|
+
* and that report, and the process exits 2. The notes are the one thing
|
|
40
|
+
* the run bought before the money ran out, so they are not thrown away.
|
|
37
41
|
*/
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Order across notes and screenshots, so a partial report can attach the
|
|
45
|
+
* screenshot nearest to each note. Module wide and only ever compared within
|
|
46
|
+
* one run, where it is monotonic.
|
|
47
|
+
*/
|
|
48
|
+
let ORDER = 0;
|
|
49
|
+
const nextOrder = () => {
|
|
50
|
+
ORDER += 1;
|
|
51
|
+
return ORDER;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** `Failed to load resource: ... status of 4xx`: a missing asset or a refused call, not a crash. */
|
|
55
|
+
const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d/;
|
|
56
|
+
|
|
57
|
+
/** A persona note kind as a report finding category. */
|
|
58
|
+
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
59
|
+
|
|
39
60
|
/** Stop injecting the "nothing changed" hint after this many identical screens. */
|
|
40
61
|
export const STUCK_HINT_AFTER = 5;
|
|
41
62
|
|
|
@@ -88,7 +109,9 @@ export async function runPersona(args) {
|
|
|
88
109
|
schemas,
|
|
89
110
|
maxActions,
|
|
90
111
|
maxImages = MAX_IMAGES_IN_CONTEXT,
|
|
91
|
-
|
|
112
|
+
// A driver may say how many observations it needs in context: a web
|
|
113
|
+
// snapshot is big and re-sent every turn, a Godot scene summary is not.
|
|
114
|
+
keepObservations = (driver && Number.isInteger(driver.keepObservations) ? driver.keepObservations : KEEP_OBSERVATIONS),
|
|
92
115
|
heartbeat = null,
|
|
93
116
|
onProgress = () => {},
|
|
94
117
|
log = () => {},
|
|
@@ -114,6 +137,7 @@ export async function runPersona(args) {
|
|
|
114
137
|
job_id: jobId,
|
|
115
138
|
persona: persona.slug,
|
|
116
139
|
driver: driverName,
|
|
140
|
+
controller: args.controller || 'standard',
|
|
117
141
|
target,
|
|
118
142
|
prompt_version: promptVersionOf(skill),
|
|
119
143
|
brief_id: brief ? brief.brief_id || null : null,
|
|
@@ -186,7 +210,15 @@ export async function runPersona(args) {
|
|
|
186
210
|
outcome.quitReason = 'cost_cap';
|
|
187
211
|
// Nothing more is asked of the model: there is no budget left to ask
|
|
188
212
|
// with, and a refused call would just be another 402.
|
|
189
|
-
|
|
213
|
+
// A report that cannot be written (a secret shaped note, a full disk)
|
|
214
|
+
// must not turn an out of budget run into a failed one.
|
|
215
|
+
try {
|
|
216
|
+
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
|
|
217
|
+
} catch (writeError) {
|
|
218
|
+
outcome.report = null;
|
|
219
|
+
outcome.reportMarkdown = null;
|
|
220
|
+
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
221
|
+
}
|
|
190
222
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
191
223
|
}
|
|
192
224
|
|
|
@@ -228,6 +260,14 @@ export async function runPersona(args) {
|
|
|
228
260
|
if (error instanceof ProxyError && error.isBudget) {
|
|
229
261
|
outcome.state = 'budget_exceeded';
|
|
230
262
|
outcome.quitReason = 'cost_cap';
|
|
263
|
+
// The money ran out in the report step itself: the notes are still
|
|
264
|
+
// worth keeping, by the same rule as a 402 during play.
|
|
265
|
+
try {
|
|
266
|
+
const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
|
|
267
|
+
await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
|
|
268
|
+
} catch (writeError) {
|
|
269
|
+
log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
|
|
270
|
+
}
|
|
231
271
|
} else if (error instanceof ProxyError && error.isClosed) {
|
|
232
272
|
outcome.state = 'canceled';
|
|
233
273
|
outcome.quitReason = 'error';
|
|
@@ -248,6 +288,8 @@ export async function runPersona(args) {
|
|
|
248
288
|
* @returns {Promise<Object>}
|
|
249
289
|
*/
|
|
250
290
|
async function playLoop({
|
|
291
|
+
api,
|
|
292
|
+
controller = 'standard',
|
|
251
293
|
client,
|
|
252
294
|
jobId,
|
|
253
295
|
runId,
|
|
@@ -270,6 +312,25 @@ async function playLoop({
|
|
|
270
312
|
log = () => {}
|
|
271
313
|
}) {
|
|
272
314
|
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
315
|
+
if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
316
|
+
const availableTools = driver.supportedActions
|
|
317
|
+
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
318
|
+
: PLAY_TOOLS;
|
|
319
|
+
const playTools = controller === 'jev' ? availableTools.map(tool => ({
|
|
320
|
+
...tool,
|
|
321
|
+
input_schema: {
|
|
322
|
+
...tool.input_schema,
|
|
323
|
+
required: [...(tool.input_schema.required || []), 'visible_state', ...(tool.name === 'click' ? ['target'] : [])],
|
|
324
|
+
properties: {
|
|
325
|
+
...tool.input_schema.properties,
|
|
326
|
+
visible_state: { type: 'string', description: 'Describe what is visibly on the current screenshot, including this action target and its coordinates. This observation is required even when the assistant text is empty. Do not claim unobserved state.' },
|
|
327
|
+
...(tool.name === 'click' ? { target: { type: 'string', description: 'Label of the visible control. For canvas also supply x and y; the driver uses those coordinates.' } } : {})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
})) : availableTools;
|
|
331
|
+
const recentActions = [];
|
|
332
|
+
let latestObservation = '';
|
|
333
|
+
let visualObservation = false;
|
|
273
334
|
const messages = [];
|
|
274
335
|
let actions = 0;
|
|
275
336
|
let turns = 0;
|
|
@@ -283,6 +344,8 @@ async function playLoop({
|
|
|
283
344
|
const maxTurns = maxActions * 3 + 20;
|
|
284
345
|
|
|
285
346
|
const first = await driver.observe();
|
|
347
|
+
latestObservation = first.text;
|
|
348
|
+
visualObservation = Boolean(first.screenshotBase64);
|
|
286
349
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
287
350
|
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
288
351
|
messages.push({
|
|
@@ -317,7 +380,7 @@ async function playLoop({
|
|
|
317
380
|
step: STEPS.personaAct,
|
|
318
381
|
system,
|
|
319
382
|
messages,
|
|
320
|
-
tools:
|
|
383
|
+
tools: playTools,
|
|
321
384
|
maxTokens: ACT_MAX_TOKENS,
|
|
322
385
|
escalate: shouldEscalate(persona),
|
|
323
386
|
imageBudget,
|
|
@@ -370,9 +433,23 @@ async function playLoop({
|
|
|
370
433
|
break;
|
|
371
434
|
}
|
|
372
435
|
|
|
436
|
+
let selection = null;
|
|
437
|
+
if (controller === 'jev') {
|
|
438
|
+
try {
|
|
439
|
+
selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
|
|
442
|
+
if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
446
|
+
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
447
|
+
if (selection.budget === 'warn') { budgetWarned = true; wrapUpLeft = Math.min(wrapUpLeft, WRAP_UP_TURNS); }
|
|
448
|
+
}
|
|
373
449
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
374
450
|
const results = [];
|
|
375
451
|
for (const toolUse of answer.toolUses) {
|
|
452
|
+
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
376
453
|
const executed = await executeTool({
|
|
377
454
|
driver,
|
|
378
455
|
toolUse,
|
|
@@ -381,11 +458,11 @@ async function playLoop({
|
|
|
381
458
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
382
459
|
// overwrite the transcript line's `kind`, and every note would
|
|
383
460
|
// read as a different sort of line.
|
|
384
|
-
notes.push({ ...note, at_action: actions });
|
|
461
|
+
notes.push({ ...note, at_action: actions, order: nextOrder() });
|
|
385
462
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
386
463
|
},
|
|
387
464
|
onScreenshot: shot => {
|
|
388
|
-
screenshots.push(shot);
|
|
465
|
+
screenshots.push({ ...shot, order: nextOrder() });
|
|
389
466
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
390
467
|
}
|
|
391
468
|
});
|
|
@@ -402,6 +479,10 @@ async function playLoop({
|
|
|
402
479
|
actions
|
|
403
480
|
});
|
|
404
481
|
if (executed.observation) {
|
|
482
|
+
latestObservation = executed.observation.text;
|
|
483
|
+
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
484
|
+
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
485
|
+
if (recentActions.length > 10) recentActions.shift();
|
|
405
486
|
transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
|
|
406
487
|
// Only an input to the game can tell us anything about being stuck. A
|
|
407
488
|
// `snapshot` or a `screenshot` never changes the screen by design, so
|
|
@@ -423,13 +504,17 @@ async function playLoop({
|
|
|
423
504
|
|
|
424
505
|
const consoleErrors = typeof driver.consoleErrors === 'function' ? await driver.consoleErrors() : [];
|
|
425
506
|
if (consoleErrors.length > 0) {
|
|
426
|
-
// An uncaught error in the game is
|
|
507
|
+
// An uncaught error in the game is worth recording whether or not the
|
|
427
508
|
// persona noticed it, so it is logged and evidenced automatically
|
|
428
|
-
// (spec 03). It
|
|
509
|
+
// (spec 03). It carries no severity: the report step decides that,
|
|
510
|
+
// with the persona's view of the screen in front of it. A 4xx on a
|
|
511
|
+
// resource is a `network` note, which the report step reads as context
|
|
512
|
+
// rather than as a bug (a missing favicon is not a broken game).
|
|
429
513
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
430
514
|
for (const entry of consoleErrors) {
|
|
431
|
-
const
|
|
432
|
-
|
|
515
|
+
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
516
|
+
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
|
|
517
|
+
notes.push({ ...note, at_action: actions, order: nextOrder() });
|
|
433
518
|
transcript.write({ kind: 'note', note });
|
|
434
519
|
}
|
|
435
520
|
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}.` : ''}`);
|
|
@@ -517,6 +602,8 @@ async function reportPhase({
|
|
|
517
602
|
'Notes you logged, in order:',
|
|
518
603
|
notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
|
|
519
604
|
'',
|
|
605
|
+
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it.',
|
|
606
|
+
'',
|
|
520
607
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
521
608
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
522
609
|
'',
|
|
@@ -642,7 +729,10 @@ async function reportPhase({
|
|
|
642
729
|
async function callWithImageRetry({ imageBudget, keepObservations, messages, onImageBudget, ...rest }) {
|
|
643
730
|
let budget = imageBudget;
|
|
644
731
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
645
|
-
|
|
732
|
+
// One moving cache breakpoint on the newest block, as the fix loop does:
|
|
733
|
+
// the prefix up to it is what the next turn re-sends, so the next turn
|
|
734
|
+
// reads it from the cache instead of paying for it again.
|
|
735
|
+
const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: budget, keepObservations }));
|
|
646
736
|
try {
|
|
647
737
|
return await callStepWithRetry({ ...rest, messages: prepared });
|
|
648
738
|
} catch (error) {
|
|
@@ -758,7 +848,7 @@ function redactToolInput(toolUse) {
|
|
|
758
848
|
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
759
849
|
try {
|
|
760
850
|
const shot = await driver.screenshot(name);
|
|
761
|
-
screenshots.push({ ...shot, reason: name });
|
|
851
|
+
screenshots.push({ ...shot, reason: name, order: nextOrder() });
|
|
762
852
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
763
853
|
return shot;
|
|
764
854
|
} catch (error) {
|
|
@@ -850,6 +940,104 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
850
940
|
].join('\n');
|
|
851
941
|
}
|
|
852
942
|
|
|
943
|
+
/**
|
|
944
|
+
* The screenshot nearest a note, by the order they happened in; a tie goes
|
|
945
|
+
* to the later one, since a persona usually notes something and then
|
|
946
|
+
* captures it.
|
|
947
|
+
*
|
|
948
|
+
* @param {Object} note
|
|
949
|
+
* @param {Array<Object>} screenshots
|
|
950
|
+
* @returns {Object|null}
|
|
951
|
+
*/
|
|
952
|
+
export function nearestScreenshot(note, screenshots) {
|
|
953
|
+
let best = null;
|
|
954
|
+
let bestDistance = Infinity;
|
|
955
|
+
for (const shot of screenshots) {
|
|
956
|
+
if (!shot || !shot.relativePath || !Number.isFinite(shot.order) || !Number.isFinite(note.order)) continue;
|
|
957
|
+
const distance = Math.abs(shot.order - note.order);
|
|
958
|
+
if (distance < bestDistance || (distance === bestDistance && best && shot.order > best.order)) {
|
|
959
|
+
best = shot;
|
|
960
|
+
bestDistance = distance;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return best || screenshots.find(shot => shot && shot.relativePath) || null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The persona's notes as report findings, for a run with no report step.
|
|
968
|
+
*
|
|
969
|
+
* Nothing is judged here, because there is no model turn to judge with:
|
|
970
|
+
* the persona's own kind becomes the category, its own severity is kept
|
|
971
|
+
* when it gave one and is `minor` otherwise (`praise` for a `good` note), and
|
|
972
|
+
* a network note is left out, since it is context rather than a finding.
|
|
973
|
+
*
|
|
974
|
+
* @param {Array<Object>} notes
|
|
975
|
+
* @param {Array<Object>} screenshots
|
|
976
|
+
* @returns {Array<Object>}
|
|
977
|
+
*/
|
|
978
|
+
export function notesToFindings(notes, screenshots) {
|
|
979
|
+
const findings = [];
|
|
980
|
+
for (const note of notes) {
|
|
981
|
+
const category = NOTE_CATEGORY[note.kind];
|
|
982
|
+
if (!category || !note.text) continue;
|
|
983
|
+
const text = String(note.text);
|
|
984
|
+
const shot = nearestScreenshot(note, screenshots);
|
|
985
|
+
findings.push({
|
|
986
|
+
id: `F${String(findings.length + 1).padStart(3, '0')}`,
|
|
987
|
+
severity: note.kind === 'good' ? 'praise' : (note.severity && note.severity !== 'praise' ? note.severity : 'minor'),
|
|
988
|
+
category,
|
|
989
|
+
title: text.length > 120 ? `${text.slice(0, 117)}...` : text,
|
|
990
|
+
description: text,
|
|
991
|
+
repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
|
|
992
|
+
evidence: shot
|
|
993
|
+
? [{ type: 'screenshot', ref: shot.relativePath }]
|
|
994
|
+
: [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return findings;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The report for a run the money ran out on: the notes as findings,
|
|
1002
|
+
* `partial: true`, and both files written. The sentiment is the scale's
|
|
1003
|
+
* midpoint and `would_continue` is false, because the schema requires both
|
|
1004
|
+
* and no persona verdict was recorded; `partial` is what tells a reader not
|
|
1005
|
+
* to read them as one.
|
|
1006
|
+
*/
|
|
1007
|
+
async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
|
|
1008
|
+
const report = stampReport({
|
|
1009
|
+
findings: notesToFindings(notes, screenshots),
|
|
1010
|
+
sentiment: { fun: 3, clarity: 3, frustration: 3 },
|
|
1011
|
+
would_continue: false,
|
|
1012
|
+
partial: true
|
|
1013
|
+
}, {
|
|
1014
|
+
runId,
|
|
1015
|
+
jobId,
|
|
1016
|
+
persona: persona.slug,
|
|
1017
|
+
driver: driverName,
|
|
1018
|
+
target,
|
|
1019
|
+
startedAt,
|
|
1020
|
+
endedAt,
|
|
1021
|
+
actionsTaken: play.actionsTaken,
|
|
1022
|
+
usage: ledger.toReportUsage(),
|
|
1023
|
+
promptVersion: promptVersionOf(skill) || 'unknown',
|
|
1024
|
+
quitReason: 'cost_cap',
|
|
1025
|
+
quitDetail: play.quitDetail
|
|
1026
|
+
});
|
|
1027
|
+
const markdown = renderFallbackMarkdown({
|
|
1028
|
+
persona,
|
|
1029
|
+
driverName,
|
|
1030
|
+
target,
|
|
1031
|
+
notes,
|
|
1032
|
+
screenshots,
|
|
1033
|
+
play,
|
|
1034
|
+
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1035
|
+
});
|
|
1036
|
+
await writeReportFiles({ runDirectory, markdown, report });
|
|
1037
|
+
outcome.report = report;
|
|
1038
|
+
outcome.reportMarkdown = markdown;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
853
1041
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
854
1042
|
const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
|
|
855
1043
|
assertNoSecrets('report.md', markdown);
|
|
@@ -868,4 +1056,4 @@ export function promptVersionOf(skill) {
|
|
|
868
1056
|
return match ? match[1] : null;
|
|
869
1057
|
}
|
|
870
1058
|
|
|
871
|
-
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown };
|
|
1059
|
+
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
|
package/src/run/resume.js
CHANGED
|
@@ -153,6 +153,7 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
153
153
|
|
|
154
154
|
const resumeOptions = {
|
|
155
155
|
...options,
|
|
156
|
+
controller: journal.options?.controller || options.controller || 'standard',
|
|
156
157
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
157
158
|
concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
|
|
158
159
|
uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
|
package/src/run/tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
|
|
1
2
|
import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -29,7 +30,16 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
|
|
|
29
30
|
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
|
|
30
31
|
|
|
31
32
|
/** Tools that spend an action. */
|
|
32
|
-
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
|
|
33
|
+
export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
|
|
34
|
+
|
|
35
|
+
export const SWIPE_TOOL = {
|
|
36
|
+
name: 'swipe',
|
|
37
|
+
description: 'Drag from one screenshot coordinate to another. Spends one action.',
|
|
38
|
+
input_schema: {
|
|
39
|
+
type: 'object', additionalProperties: false, required: ['x', 'y', 'toX', 'toY'],
|
|
40
|
+
properties: Object.fromEntries(['x', 'y', 'toX', 'toY', 'durationMs'].map(key => [key, { type: 'number', minimum: 0, maximum: key === 'durationMs' ? 2000 : 32768 }]))
|
|
41
|
+
}
|
|
42
|
+
};
|
|
33
43
|
|
|
34
44
|
const thought = {
|
|
35
45
|
type: 'string',
|
|
@@ -185,6 +195,8 @@ export function actionFor(toolUse) {
|
|
|
185
195
|
return { kind: 'navigate', url: String(input.url || '') };
|
|
186
196
|
case 'wait':
|
|
187
197
|
return { kind: 'wait', ms: Number(input.ms) || 0 };
|
|
198
|
+
case 'swipe':
|
|
199
|
+
return { kind: 'swipe', from: { x: input.x, y: input.y }, to: { x: input.toX, y: input.toY }, durationMs: input.durationMs };
|
|
188
200
|
default:
|
|
189
201
|
return null;
|
|
190
202
|
}
|
|
@@ -289,13 +301,17 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
|
|
|
289
301
|
* @param {boolean} [isError]
|
|
290
302
|
*/
|
|
291
303
|
export function observationResult(id, observation, prefix = '', isError = false) {
|
|
304
|
+
// The same screen as last time is a marker, not a resend: the persona
|
|
305
|
+
// already has it in context, and a stuck run would otherwise pay for the
|
|
306
|
+
// whole snapshot (and its image) on every turn it stays stuck.
|
|
307
|
+
const body = observation.unchanged ? UNCHANGED_OBSERVATION_TEXT : truncateObservation(observation.text, OBSERVATION_MAX_CHARS);
|
|
292
308
|
const content = [
|
|
293
309
|
{
|
|
294
310
|
type: 'text',
|
|
295
|
-
text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${
|
|
311
|
+
text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${body}`
|
|
296
312
|
}
|
|
297
313
|
];
|
|
298
|
-
if (observation.screenshotBase64) {
|
|
314
|
+
if (observation.screenshotBase64 && !observation.unchanged) {
|
|
299
315
|
content.push({
|
|
300
316
|
type: 'image',
|
|
301
317
|
source: {
|
package/src/states.js
CHANGED
package/src/ui/index.js
CHANGED
|
@@ -157,6 +157,33 @@ export async function confirm(question, options = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Ask the person to pick one of a numbered list. Like `confirm`, it never
|
|
162
|
+
* asks without a terminal: with no TTY (CI, a pipe) it answers null at once,
|
|
163
|
+
* and so does an answer that is not one of the numbers shown.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} question e.g. "Which game? [1-3]"
|
|
166
|
+
* @param {Array<string>} labels one line per choice, printed numbered from 1
|
|
167
|
+
* @param {{input?: object, output?: object}} [options]
|
|
168
|
+
* @returns {Promise<number|null>} the zero based index, or null
|
|
169
|
+
*/
|
|
170
|
+
export async function choose(question, labels, options = {}) {
|
|
171
|
+
const input = options.input || process.stdin;
|
|
172
|
+
if (!input.isTTY) return null;
|
|
173
|
+
const output = options.output || stream;
|
|
174
|
+
|
|
175
|
+
labels.forEach((label, index) => output.write(` ${index + 1}. ${label}\n`));
|
|
176
|
+
const rl = createInterface({ input, output });
|
|
177
|
+
try {
|
|
178
|
+
const answer = (await rl.question(`${question} `)).trim();
|
|
179
|
+
if (!/^\d+$/.test(answer)) return null;
|
|
180
|
+
const index = Number(answer) - 1;
|
|
181
|
+
return index >= 0 && index < labels.length ? index : null;
|
|
182
|
+
} finally {
|
|
183
|
+
rl.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
/** Cents as dollars, the one place that formatting exists. */
|
|
161
188
|
export function money(cents) {
|
|
162
189
|
if (cents === null || cents === undefined) return 'unknown';
|
|
@@ -190,6 +217,7 @@ export const ui = {
|
|
|
190
217
|
table,
|
|
191
218
|
spinner,
|
|
192
219
|
confirm,
|
|
220
|
+
choose,
|
|
193
221
|
money,
|
|
194
222
|
bytes
|
|
195
223
|
};
|