ravensight-playtest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
4
|
+
import { observationHash } from './drivers/driver.js';
|
|
5
|
+
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
6
|
+
import { RUN_FILES } from './paths.js';
|
|
7
|
+
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
8
|
+
import { assertNoSecrets } from './secretScan.js';
|
|
9
|
+
import { ACTION_TOOLS, PLAY_TOOLS, REPORT_TOOLS, executeTool } from './tools.js';
|
|
10
|
+
import { createTranscript } from './transcript.js';
|
|
11
|
+
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
15
|
+
*
|
|
16
|
+
* The two phases are two different steps at the proxy
|
|
17
|
+
* (`persona_playtest.act` and `persona_playtest.report`), with different
|
|
18
|
+
* tool allowlists and different token ceilings, and REPORTING reads only
|
|
19
|
+
* stored inputs. That split is what makes `regenerate` possible: the report
|
|
20
|
+
* can be rebuilt later from `transcript.jsonl` plus the screenshots, without
|
|
21
|
+
* the game running and without replaying anything.
|
|
22
|
+
*
|
|
23
|
+
* Billing shapes the control flow more than anything else does. A model turn
|
|
24
|
+
* is the billable unit, so every turn here has to be worth its money:
|
|
25
|
+
*
|
|
26
|
+
* - The screen is observed once per turn, inside the tool result the model
|
|
27
|
+
* already asked for, rather than as a separate turn.
|
|
28
|
+
* - Old observations and old images fall out of context (./context.js), so
|
|
29
|
+
* turn twenty does not pay for turn one's screen again.
|
|
30
|
+
* - `X-Playtest-Budget: warn` at 80 percent of the run budget starts a wrap
|
|
31
|
+
* up, so the report (which is the product) gets written while there is
|
|
32
|
+
* still budget to write it with.
|
|
33
|
+
* - A 402 means the money is gone. Nothing further is asked of the model,
|
|
34
|
+
* including the report: a partial `report.md` is written locally from the
|
|
35
|
+
* notes, `complete` is sent with `state: budget_exceeded` and no
|
|
36
|
+
* `report`, and the process exits 2.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Stop injecting the "nothing changed" hint after this many identical screens. */
|
|
40
|
+
export const STUCK_HINT_AFTER = 5;
|
|
41
|
+
|
|
42
|
+
/** Give up entirely after this many identical screens: the persona is wedged. */
|
|
43
|
+
export const STUCK_LIMIT = 12;
|
|
44
|
+
|
|
45
|
+
/** Turns allowed after a budget warning, to land the report. */
|
|
46
|
+
export const WRAP_UP_TURNS = 3;
|
|
47
|
+
|
|
48
|
+
/** Token ceiling asked for per act turn. The proxy clamps it to the step's own. */
|
|
49
|
+
export const ACT_MAX_TOKENS = 1024;
|
|
50
|
+
|
|
51
|
+
/** Token ceiling asked for when writing the report. */
|
|
52
|
+
export const REPORT_MAX_TOKENS = 4096;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} PersonaRunOutcome
|
|
56
|
+
* @property {string} state one of succeeded, failed, canceled, budget_exceeded
|
|
57
|
+
* @property {string} quitReason a report schema quit_reason
|
|
58
|
+
* @property {string} [quitDetail]
|
|
59
|
+
* @property {number} actionsTaken
|
|
60
|
+
* @property {Object|null} report the validated report.json, null when none
|
|
61
|
+
* was written
|
|
62
|
+
* @property {boolean} budgetWarned
|
|
63
|
+
* @property {Array<string>} reportLint
|
|
64
|
+
* @property {string|null} videoPath
|
|
65
|
+
* @property {Object} usage
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {Object} args
|
|
70
|
+
* @returns {Promise<PersonaRunOutcome>}
|
|
71
|
+
*/
|
|
72
|
+
export async function runPersona(args) {
|
|
73
|
+
const {
|
|
74
|
+
client,
|
|
75
|
+
api,
|
|
76
|
+
gameId,
|
|
77
|
+
jobId,
|
|
78
|
+
runId,
|
|
79
|
+
persona,
|
|
80
|
+
module: moduleName = 'persona_playtest',
|
|
81
|
+
driver,
|
|
82
|
+
driverName,
|
|
83
|
+
target,
|
|
84
|
+
runDirectory,
|
|
85
|
+
skill,
|
|
86
|
+
brief = null,
|
|
87
|
+
intentNotes = [],
|
|
88
|
+
schemas,
|
|
89
|
+
maxActions,
|
|
90
|
+
maxImages = MAX_IMAGES_IN_CONTEXT,
|
|
91
|
+
keepObservations = KEEP_OBSERVATIONS,
|
|
92
|
+
heartbeat = null,
|
|
93
|
+
onProgress = () => {},
|
|
94
|
+
log = () => {},
|
|
95
|
+
recordVideo = false,
|
|
96
|
+
viewport
|
|
97
|
+
} = args;
|
|
98
|
+
|
|
99
|
+
const validator = createReportValidator(schemas);
|
|
100
|
+
// The caller may own the ledger, which is how the run's authoritative spend
|
|
101
|
+
// gets into it: the heartbeat answer carries `run.usage.total_usd`, and only
|
|
102
|
+
// the caller has the heartbeat.
|
|
103
|
+
const ledger = args.ledger || createUsageLedger();
|
|
104
|
+
const startedAt = new Date().toISOString();
|
|
105
|
+
const notes = [];
|
|
106
|
+
const screenshots = [];
|
|
107
|
+
let imageBudget = maxImages;
|
|
108
|
+
|
|
109
|
+
await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
|
|
110
|
+
const transcript = await createTranscript(runDirectory).open();
|
|
111
|
+
transcript.write({
|
|
112
|
+
kind: 'header',
|
|
113
|
+
run_id: runId,
|
|
114
|
+
job_id: jobId,
|
|
115
|
+
persona: persona.slug,
|
|
116
|
+
driver: driverName,
|
|
117
|
+
target,
|
|
118
|
+
prompt_version: promptVersionOf(skill),
|
|
119
|
+
brief_id: brief ? brief.brief_id || null : null,
|
|
120
|
+
brief_version: brief ? brief.version ?? null : null,
|
|
121
|
+
max_actions: maxActions
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let outcome = {
|
|
125
|
+
state: 'failed',
|
|
126
|
+
quitReason: 'error',
|
|
127
|
+
quitDetail: '',
|
|
128
|
+
actionsTaken: 0,
|
|
129
|
+
report: null,
|
|
130
|
+
budgetWarned: false,
|
|
131
|
+
reportLint: [],
|
|
132
|
+
videoPath: null,
|
|
133
|
+
usage: null,
|
|
134
|
+
// The last state the SERVER accepted, which is not the same thing as the
|
|
135
|
+
// outcome: the caller has to know whether this run ever reached
|
|
136
|
+
// `reporting`, because the server's table only allows `uploading` from
|
|
137
|
+
// there and a hop it refuses is a 409, not a no-op.
|
|
138
|
+
reachedState: 'queued'
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Move the run and remember where it got to.
|
|
143
|
+
* @param {string} state
|
|
144
|
+
* @param {string} [reason]
|
|
145
|
+
*/
|
|
146
|
+
async function advance(state, reason = '') {
|
|
147
|
+
await transition(api, gameId, jobId, runId, state, reason);
|
|
148
|
+
outcome.reachedState = state;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
// `launching` first, then launch: the state means "we are starting the
|
|
153
|
+
// game", and a launch that hangs or dies should leave the server holding
|
|
154
|
+
// that rather than still holding `queued`.
|
|
155
|
+
await advance('launching');
|
|
156
|
+
await driver.launch({ target, runDir: runDirectory, recordVideo, viewport });
|
|
157
|
+
await advance('playing');
|
|
158
|
+
|
|
159
|
+
const play = await playLoop({
|
|
160
|
+
...args,
|
|
161
|
+
transcript,
|
|
162
|
+
ledger,
|
|
163
|
+
notes,
|
|
164
|
+
screenshots,
|
|
165
|
+
imageBudget,
|
|
166
|
+
keepObservations,
|
|
167
|
+
onImageBudget: next => {
|
|
168
|
+
imageBudget = next;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
imageBudget = play.imageBudget;
|
|
172
|
+
outcome = { ...outcome, ...play };
|
|
173
|
+
|
|
174
|
+
const endedAt = new Date().toISOString();
|
|
175
|
+
await driverStop(driver, outcome, log);
|
|
176
|
+
|
|
177
|
+
if (play.canceled) {
|
|
178
|
+
outcome.state = 'canceled';
|
|
179
|
+
outcome.quitReason = play.quitReason || 'error';
|
|
180
|
+
await writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason: 'The run was cancelled before the report was written.' });
|
|
181
|
+
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (play.budgetExceeded) {
|
|
185
|
+
outcome.state = 'budget_exceeded';
|
|
186
|
+
outcome.quitReason = 'cost_cap';
|
|
187
|
+
// Nothing more is asked of the model: there is no budget left to ask
|
|
188
|
+
// with, and a refused call would just be another 402.
|
|
189
|
+
await writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason: 'The run reached its model budget before the report step. What the persona saw is below; there is no report.json for this run.' });
|
|
190
|
+
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await advance('reporting');
|
|
194
|
+
onProgress({ phase: 'reporting', persona: persona.slug });
|
|
195
|
+
|
|
196
|
+
const written = await reportPhase({
|
|
197
|
+
client,
|
|
198
|
+
jobId,
|
|
199
|
+
runId,
|
|
200
|
+
persona,
|
|
201
|
+
driverName,
|
|
202
|
+
target,
|
|
203
|
+
runDirectory,
|
|
204
|
+
schemas,
|
|
205
|
+
validator,
|
|
206
|
+
skill: args.reportSkill || skill,
|
|
207
|
+
brief,
|
|
208
|
+
intentNotes,
|
|
209
|
+
transcript,
|
|
210
|
+
ledger,
|
|
211
|
+
notes,
|
|
212
|
+
screenshots,
|
|
213
|
+
play,
|
|
214
|
+
startedAt,
|
|
215
|
+
endedAt,
|
|
216
|
+
log
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
outcome.report = written.report;
|
|
220
|
+
outcome.reportMarkdown = written.markdown;
|
|
221
|
+
outcome.reportLint = written.lint;
|
|
222
|
+
outcome.state = 'succeeded';
|
|
223
|
+
outcome.quitReason = written.report.quit_reason;
|
|
224
|
+
outcome.quitDetail = written.report.quit_detail || '';
|
|
225
|
+
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await driverStop(driver, outcome, log);
|
|
228
|
+
if (error instanceof ProxyError && error.isBudget) {
|
|
229
|
+
outcome.state = 'budget_exceeded';
|
|
230
|
+
outcome.quitReason = 'cost_cap';
|
|
231
|
+
} else if (error instanceof ProxyError && error.isClosed) {
|
|
232
|
+
outcome.state = 'canceled';
|
|
233
|
+
outcome.quitReason = 'error';
|
|
234
|
+
} else {
|
|
235
|
+
outcome.state = 'failed';
|
|
236
|
+
outcome.quitReason = 'error';
|
|
237
|
+
}
|
|
238
|
+
outcome.quitDetail = String(error && error.message ? error.message : error);
|
|
239
|
+
transcript.write({ kind: 'error', message: outcome.quitDetail });
|
|
240
|
+
await finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
241
|
+
throw Object.assign(error, { outcome });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The PLAYING phase.
|
|
247
|
+
*
|
|
248
|
+
* @returns {Promise<Object>}
|
|
249
|
+
*/
|
|
250
|
+
async function playLoop({
|
|
251
|
+
client,
|
|
252
|
+
jobId,
|
|
253
|
+
runId,
|
|
254
|
+
persona,
|
|
255
|
+
driver,
|
|
256
|
+
driverName,
|
|
257
|
+
target,
|
|
258
|
+
skill,
|
|
259
|
+
brief,
|
|
260
|
+
intentNotes,
|
|
261
|
+
maxActions,
|
|
262
|
+
transcript,
|
|
263
|
+
ledger,
|
|
264
|
+
notes,
|
|
265
|
+
screenshots,
|
|
266
|
+
imageBudget,
|
|
267
|
+
keepObservations,
|
|
268
|
+
heartbeat,
|
|
269
|
+
onProgress = () => {},
|
|
270
|
+
log = () => {}
|
|
271
|
+
}) {
|
|
272
|
+
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
273
|
+
const messages = [];
|
|
274
|
+
let actions = 0;
|
|
275
|
+
let turns = 0;
|
|
276
|
+
let quitReason = 'budget_exhausted';
|
|
277
|
+
let quitDetail = '';
|
|
278
|
+
let budgetWarned = false;
|
|
279
|
+
let budgetExceeded = false;
|
|
280
|
+
let canceled = false;
|
|
281
|
+
let unchanged = 0;
|
|
282
|
+
let wrapUpLeft = Infinity;
|
|
283
|
+
const maxTurns = maxActions * 3 + 20;
|
|
284
|
+
|
|
285
|
+
const first = await driver.observe();
|
|
286
|
+
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
287
|
+
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
288
|
+
messages.push({
|
|
289
|
+
role: 'user',
|
|
290
|
+
content: [
|
|
291
|
+
{ type: 'text', text: `You are in front of the game now. ${landing ? `A screenshot is saved at ${landing.relativePath}. ` : ''}step ${first.step}${first.url ? ` (${first.url})` : ''}\n${first.text}` },
|
|
292
|
+
...(first.screenshotBase64
|
|
293
|
+
? [{ type: 'image', source: { type: 'base64', media_type: first.screenshotMediaType || 'image/png', data: first.screenshotBase64 } }]
|
|
294
|
+
: [])
|
|
295
|
+
]
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
while (turns < maxTurns && actions < maxActions) {
|
|
299
|
+
if (heartbeat && heartbeat.canceled) {
|
|
300
|
+
canceled = true;
|
|
301
|
+
quitReason = 'error';
|
|
302
|
+
quitDetail = 'the job was cancelled';
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
if (wrapUpLeft <= 0) {
|
|
306
|
+
quitReason = 'budget_exhausted';
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
turns += 1;
|
|
311
|
+
let answer;
|
|
312
|
+
try {
|
|
313
|
+
answer = await callWithImageRetry({
|
|
314
|
+
client,
|
|
315
|
+
jobId,
|
|
316
|
+
runId,
|
|
317
|
+
step: STEPS.personaAct,
|
|
318
|
+
system,
|
|
319
|
+
messages,
|
|
320
|
+
tools: PLAY_TOOLS,
|
|
321
|
+
maxTokens: ACT_MAX_TOKENS,
|
|
322
|
+
escalate: shouldEscalate(persona),
|
|
323
|
+
imageBudget,
|
|
324
|
+
keepObservations,
|
|
325
|
+
onImageBudget: next => {
|
|
326
|
+
imageBudget = next;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error instanceof ProxyError && error.isBudget) {
|
|
331
|
+
budgetExceeded = true;
|
|
332
|
+
quitReason = 'cost_cap';
|
|
333
|
+
quitDetail = error.message;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (error instanceof ProxyError && error.isClosed) {
|
|
337
|
+
canceled = true;
|
|
338
|
+
quitDetail = error.message;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
ledger.record({ model: answer.model, usage: answer.usage });
|
|
345
|
+
// The warn turn is a turn the developer paid for, so its tool calls are
|
|
346
|
+
// executed like any other and the wrap-up notice rides along on that
|
|
347
|
+
// turn's tool_result. Two things this avoids: discarding a paid action,
|
|
348
|
+
// and pushing a second consecutive `user` message, which the Messages API
|
|
349
|
+
// refuses when the previous turn ended in a tool_use.
|
|
350
|
+
let wrapUpNotice = null;
|
|
351
|
+
if (answer.budget === 'warn' && !budgetWarned) {
|
|
352
|
+
budgetWarned = true;
|
|
353
|
+
wrapUpLeft = WRAP_UP_TURNS;
|
|
354
|
+
// Surfaced, not swallowed: this is the developer's money and the one
|
|
355
|
+
// moment where telling them costs nothing and saying nothing costs a
|
|
356
|
+
// report.
|
|
357
|
+
log(`budget warning: this run has used at least 80 percent of its model budget. Wrapping up after ${WRAP_UP_TURNS} more turns so the report still gets written.`);
|
|
358
|
+
wrapUpNotice = `You are nearly out of budget for this session. Take a final screenshot if you need one, then stop calling tools so the report can be written. You have ${WRAP_UP_TURNS} turns left.`;
|
|
359
|
+
transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
|
|
360
|
+
}
|
|
361
|
+
if (budgetWarned) wrapUpLeft -= 1;
|
|
362
|
+
|
|
363
|
+
if (answer.toolUses.length === 0) {
|
|
364
|
+
// No tool call is how a persona stops: there is no quit tool in the
|
|
365
|
+
// step's allowlist, and adding one would be refused by the proxy. A warn
|
|
366
|
+
// on such a turn needs no notice either, since the loop ends here.
|
|
367
|
+
quitReason = budgetWarned && wrapUpNotice ? 'budget_exhausted' : 'persona_quit';
|
|
368
|
+
quitDetail = answer.text.slice(0, 400);
|
|
369
|
+
transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
374
|
+
const results = [];
|
|
375
|
+
for (const toolUse of answer.toolUses) {
|
|
376
|
+
const executed = await executeTool({
|
|
377
|
+
driver,
|
|
378
|
+
toolUse,
|
|
379
|
+
onNote: note => {
|
|
380
|
+
// The note is nested rather than spread: its own `kind`
|
|
381
|
+
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
382
|
+
// overwrite the transcript line's `kind`, and every note would
|
|
383
|
+
// read as a different sort of line.
|
|
384
|
+
notes.push({ ...note, at_action: actions });
|
|
385
|
+
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
386
|
+
},
|
|
387
|
+
onScreenshot: shot => {
|
|
388
|
+
screenshots.push(shot);
|
|
389
|
+
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
results.push(executed.result);
|
|
393
|
+
if (executed.spentAction) actions += 1;
|
|
394
|
+
transcript.write({
|
|
395
|
+
kind: 'action',
|
|
396
|
+
tool: toolUse.name,
|
|
397
|
+
input: redactToolInput(toolUse),
|
|
398
|
+
thought: (toolUse.input && toolUse.input.thought_in_character) || null,
|
|
399
|
+
ok: !executed.error,
|
|
400
|
+
error: executed.error,
|
|
401
|
+
changed: executed.changed,
|
|
402
|
+
actions
|
|
403
|
+
});
|
|
404
|
+
if (executed.observation) {
|
|
405
|
+
transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
|
|
406
|
+
// Only an input to the game can tell us anything about being stuck. A
|
|
407
|
+
// `snapshot` or a `screenshot` never changes the screen by design, so
|
|
408
|
+
// counting those would have a persona that looks twice before acting
|
|
409
|
+
// declared wedged for doing the right thing.
|
|
410
|
+
if (ACTION_TOOLS.includes(toolUse.name)) unchanged = executed.changed ? 0 : unchanged + 1;
|
|
411
|
+
}
|
|
412
|
+
onProgress({ phase: 'playing', persona: persona.slug, actions, maxActions, tool: toolUse.name });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Everything the runner wants to tell the model about this turn goes into
|
|
416
|
+
// the SAME user message as the tool results: console errors, the budget
|
|
417
|
+
// wrap-up, the stuck hint. Two reasons it is not a second message. The
|
|
418
|
+
// Messages API requires alternating roles, so a second consecutive `user`
|
|
419
|
+
// turn is refused outright. And a fabricated `tool_use_id` (which is what
|
|
420
|
+
// an "auto-console" tool_result would be) has no matching `tool_use` in
|
|
421
|
+
// the assistant turn, which is refused as well.
|
|
422
|
+
const asides = [];
|
|
423
|
+
|
|
424
|
+
const consoleErrors = typeof driver.consoleErrors === 'function' ? await driver.consoleErrors() : [];
|
|
425
|
+
if (consoleErrors.length > 0) {
|
|
426
|
+
// An uncaught error in the game is a finding whether or not the
|
|
427
|
+
// persona noticed it, so it is logged and evidenced automatically
|
|
428
|
+
// (spec 03). It is the model's job to decide the severity.
|
|
429
|
+
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
430
|
+
for (const entry of consoleErrors) {
|
|
431
|
+
const note = { kind: 'bug', severity: 'major', text: `${entry.type}: ${entry.text}`, automatic: true };
|
|
432
|
+
notes.push(note);
|
|
433
|
+
transcript.write({ kind: 'note', note });
|
|
434
|
+
}
|
|
435
|
+
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}.` : ''}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (wrapUpNotice) asides.push(wrapUpNotice);
|
|
439
|
+
|
|
440
|
+
const wedged = unchanged >= STUCK_LIMIT;
|
|
441
|
+
if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
|
|
442
|
+
transcript.write({ kind: 'stuck', unchanged, fatal: false });
|
|
443
|
+
asides.push(`The screen has not changed for ${unchanged} actions. Your persona would notice that. Try something different, or stop.`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
messages.push({
|
|
447
|
+
role: 'user',
|
|
448
|
+
content: asides.length > 0
|
|
449
|
+
? [...results, { type: 'text', text: asides.join('\n\n') }]
|
|
450
|
+
: results
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (wedged) {
|
|
454
|
+
quitReason = 'blocker';
|
|
455
|
+
quitDetail = `the screen did not change for ${unchanged} actions`;
|
|
456
|
+
transcript.write({ kind: 'stuck', unchanged, fatal: true });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (heartbeat) await heartbeat.beat({ actionsTaken: actions, checkpointStep: turns });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (actions >= maxActions && quitReason === 'budget_exhausted') {
|
|
464
|
+
quitReason = 'budget_exhausted';
|
|
465
|
+
quitDetail = `the action budget of ${maxActions} ran out`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const final = await safeScreenshot(driver, 'final', screenshots, transcript);
|
|
469
|
+
return {
|
|
470
|
+
actionsTaken: actions,
|
|
471
|
+
turns,
|
|
472
|
+
quitReason,
|
|
473
|
+
quitDetail,
|
|
474
|
+
budgetWarned,
|
|
475
|
+
budgetExceeded,
|
|
476
|
+
canceled,
|
|
477
|
+
imageBudget,
|
|
478
|
+
finalScreenshot: final ? final.relativePath : null
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The REPORTING phase: a separate call, a separate step, a separate tool
|
|
484
|
+
* allowlist (`write_file` only), and only stored inputs.
|
|
485
|
+
*/
|
|
486
|
+
async function reportPhase({
|
|
487
|
+
client,
|
|
488
|
+
jobId,
|
|
489
|
+
runId,
|
|
490
|
+
persona,
|
|
491
|
+
driverName,
|
|
492
|
+
target,
|
|
493
|
+
runDirectory,
|
|
494
|
+
validator,
|
|
495
|
+
skill,
|
|
496
|
+
brief,
|
|
497
|
+
intentNotes,
|
|
498
|
+
transcript,
|
|
499
|
+
ledger,
|
|
500
|
+
notes,
|
|
501
|
+
screenshots,
|
|
502
|
+
play,
|
|
503
|
+
startedAt,
|
|
504
|
+
endedAt,
|
|
505
|
+
log
|
|
506
|
+
}) {
|
|
507
|
+
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken });
|
|
508
|
+
const messages = [{
|
|
509
|
+
role: 'user',
|
|
510
|
+
content: [{
|
|
511
|
+
type: 'text',
|
|
512
|
+
text: [
|
|
513
|
+
`The session is over. Write report.md and report.json for run ${runId} of job ${jobId}.`,
|
|
514
|
+
`persona: ${persona.slug} driver: ${driverName} target: ${target}`,
|
|
515
|
+
`actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
|
|
516
|
+
'',
|
|
517
|
+
'Notes you logged, in order:',
|
|
518
|
+
notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
|
|
519
|
+
'',
|
|
520
|
+
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
521
|
+
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
522
|
+
'',
|
|
523
|
+
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
524
|
+
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
525
|
+
].join('\n')
|
|
526
|
+
}]
|
|
527
|
+
}];
|
|
528
|
+
|
|
529
|
+
let report = null;
|
|
530
|
+
let markdown = null;
|
|
531
|
+
let lint = [];
|
|
532
|
+
|
|
533
|
+
// Two attempts at most: the first, and one repair if the schema or the
|
|
534
|
+
// evidence refs refuse it. Each attempt is a metered question, and the
|
|
535
|
+
// dogfood gate asks for "first or the repair attempt", not an
|
|
536
|
+
// open ended loop.
|
|
537
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
538
|
+
const answer = await callStepWithRetry({
|
|
539
|
+
client,
|
|
540
|
+
jobId,
|
|
541
|
+
runId,
|
|
542
|
+
step: STEPS.personaReport,
|
|
543
|
+
system,
|
|
544
|
+
messages,
|
|
545
|
+
tools: REPORT_TOOLS,
|
|
546
|
+
maxTokens: REPORT_MAX_TOKENS,
|
|
547
|
+
escalate: shouldEscalate(persona)
|
|
548
|
+
});
|
|
549
|
+
ledger.record({ model: answer.model, usage: answer.usage });
|
|
550
|
+
transcript.write({ kind: 'report_turn', attempt, stop_reason: answer.stopReason, files: answer.toolUses.map(t => t.input && t.input.path) });
|
|
551
|
+
|
|
552
|
+
const files = new Map();
|
|
553
|
+
for (const toolUse of answer.toolUses) {
|
|
554
|
+
if (toolUse.name !== 'write_file' || toolUse.malformed) continue;
|
|
555
|
+
const name = String(toolUse.input.path || '');
|
|
556
|
+
if (name !== RUN_FILES.reportMd && name !== RUN_FILES.reportJson) continue;
|
|
557
|
+
files.set(name, String(toolUse.input.content || ''));
|
|
558
|
+
}
|
|
559
|
+
if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
|
|
560
|
+
|
|
561
|
+
let parsed = null;
|
|
562
|
+
let parseError = null;
|
|
563
|
+
if (files.has(RUN_FILES.reportJson)) {
|
|
564
|
+
try {
|
|
565
|
+
parsed = JSON.parse(files.get(RUN_FILES.reportJson));
|
|
566
|
+
} catch (error) {
|
|
567
|
+
parseError = String(error && error.message);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const facts = {
|
|
572
|
+
runId,
|
|
573
|
+
jobId,
|
|
574
|
+
persona: persona.slug,
|
|
575
|
+
driver: driverName,
|
|
576
|
+
target,
|
|
577
|
+
startedAt,
|
|
578
|
+
endedAt,
|
|
579
|
+
actionsTaken: play.actionsTaken,
|
|
580
|
+
usage: ledger.toReportUsage(),
|
|
581
|
+
promptVersion: promptVersionOf(skill),
|
|
582
|
+
quitReason: play.quitReason,
|
|
583
|
+
quitDetail: play.quitDetail
|
|
584
|
+
};
|
|
585
|
+
const candidate = parsed ? stampReport(parsed, facts) : null;
|
|
586
|
+
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
587
|
+
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
588
|
+
// The server lints the same four sections at `complete` and records the
|
|
589
|
+
// result on the run, where nobody sees it until after the run is over. One
|
|
590
|
+
// repair turn here is cheaper than a report the developer has to read
|
|
591
|
+
// around.
|
|
592
|
+
// A markdown that was never written is missing all four, which is what
|
|
593
|
+
// asks for it in the repair turn rather than silently falling back.
|
|
594
|
+
const missingSections = lintMarkdownSections(markdown || '');
|
|
595
|
+
|
|
596
|
+
if (validation.ok && evidenceProblems.length === 0 && missingSections.length === 0) {
|
|
597
|
+
report = candidate;
|
|
598
|
+
lint = [];
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
lint = [
|
|
603
|
+
...validation.details.map(detail => `${detail.instancePath}: ${detail.message}`),
|
|
604
|
+
...evidenceProblems,
|
|
605
|
+
...missingSections.map(section => `report.md has no "${section}" section`)
|
|
606
|
+
];
|
|
607
|
+
if (attempt === 2) {
|
|
608
|
+
if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
|
|
609
|
+
// Second attempt still imperfect: keep it, record the lint, and let
|
|
610
|
+
// the server have the final word. It validates the same schema and
|
|
611
|
+
// drops unevidenced findings itself, so this is a report with known
|
|
612
|
+
// problems rather than no report at all.
|
|
613
|
+
report = candidate;
|
|
614
|
+
log(`report.json still has problems after one repair: ${lint.join('; ')}`);
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
619
|
+
messages.push({
|
|
620
|
+
role: 'user',
|
|
621
|
+
content: answer.toolUses
|
|
622
|
+
.filter(toolUse => !toolUse.malformed)
|
|
623
|
+
.map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
|
|
624
|
+
.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!markdown) {
|
|
629
|
+
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await writeReportFiles({ runDirectory, markdown, report });
|
|
633
|
+
return { report, markdown, lint };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* `callStepWithRetry`, plus the one retry that is about our own request
|
|
638
|
+
* rather than the server's mood: `too_many_images` means this step allows
|
|
639
|
+
* fewer images than we sent, so halve and send again instead of losing the
|
|
640
|
+
* run over a policy number the CLI is not allowed to read.
|
|
641
|
+
*/
|
|
642
|
+
async function callWithImageRetry({ imageBudget, keepObservations, messages, onImageBudget, ...rest }) {
|
|
643
|
+
let budget = imageBudget;
|
|
644
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
645
|
+
const prepared = prepareMessages(messages, { maxImages: budget, keepObservations });
|
|
646
|
+
try {
|
|
647
|
+
return await callStepWithRetry({ ...rest, messages: prepared });
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (!(error instanceof ProxyError) || error.code !== 'too_many_images' || budget === 0) throw error;
|
|
650
|
+
budget = shrinkImages(budget);
|
|
651
|
+
if (onImageBudget) onImageBudget(budget);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
throw new ProxyError({ status: 400, code: 'too_many_images', message: 'This step refused every image budget we tried.' });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The system prompt: the skill, then the persona, then the brief and the
|
|
659
|
+
* design intent notes.
|
|
660
|
+
*
|
|
661
|
+
* A cache breakpoint goes on the last block because all of it is identical
|
|
662
|
+
* across every turn of a run, and the act step is the one place in this
|
|
663
|
+
* product where the same few thousand tokens are re-sent tens of times. The
|
|
664
|
+
* proxy allowlists `system` and passes the blocks through unchanged.
|
|
665
|
+
*/
|
|
666
|
+
export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
|
|
667
|
+
return [
|
|
668
|
+
{ type: 'text', text: skill },
|
|
669
|
+
{ type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
|
|
670
|
+
{
|
|
671
|
+
type: 'text',
|
|
672
|
+
text: [
|
|
673
|
+
`driver: ${driverName}`,
|
|
674
|
+
`target: ${target}`,
|
|
675
|
+
`max_actions: ${maxActions}`,
|
|
676
|
+
`<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
|
|
677
|
+
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`
|
|
678
|
+
].join('\n'),
|
|
679
|
+
cache_control: { type: 'ephemeral' }
|
|
680
|
+
}
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The brief as the persona skill expects it: the `fields` object the
|
|
686
|
+
* expectations-brief schema describes, with the version alongside so a report
|
|
687
|
+
* can be attributed to it. The wrapper the API answers with (`brief_id`,
|
|
688
|
+
* `status`, `change_summary`, `created_by`) is not something a persona has any
|
|
689
|
+
* use for, and every token of it is re-sent on every turn of the run.
|
|
690
|
+
*
|
|
691
|
+
* `null` when there is no brief, which is the branch the skill's "when
|
|
692
|
+
* expectations_brief is not null" rules hang off.
|
|
693
|
+
*
|
|
694
|
+
* @param {Object|null} brief
|
|
695
|
+
* @returns {string}
|
|
696
|
+
*/
|
|
697
|
+
export function briefForPrompt(brief) {
|
|
698
|
+
if (!brief) return 'null';
|
|
699
|
+
const fields = brief.fields && typeof brief.fields === 'object' ? brief.fields : brief;
|
|
700
|
+
return JSON.stringify({ version: brief.version ?? null, ...fields }, null, 2);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Design intent comes from two places and both are binding: the brief's own
|
|
705
|
+
* `design_intent_notes`, and the notes the server derives from `intended`
|
|
706
|
+
* marks on earlier findings (the pack bundle's `intent_notes`). Anything
|
|
707
|
+
* matching either is deliberate and is never a finding.
|
|
708
|
+
*
|
|
709
|
+
* @param {Object|null} brief
|
|
710
|
+
* @param {Array<Object>} intentNotes
|
|
711
|
+
* @returns {string}
|
|
712
|
+
*/
|
|
713
|
+
export function intentNotesForPrompt(brief, intentNotes) {
|
|
714
|
+
const fields = (brief && brief.fields) || {};
|
|
715
|
+
const fromBrief = Array.isArray(fields.design_intent_notes) ? fields.design_intent_notes : [];
|
|
716
|
+
const lines = [
|
|
717
|
+
...fromBrief.map(note => `- ${typeof note === 'string' ? note : note.text || JSON.stringify(note)}`),
|
|
718
|
+
...(intentNotes || []).map(note => `- ${note.text}${note.scope ? ` (scope: ${note.scope})` : ''}`)
|
|
719
|
+
];
|
|
720
|
+
return lines.length > 0 ? lines.join('\n') : '(none)';
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* A persona file may hint a tier. The hint only ever raises the tier, never
|
|
725
|
+
* lowers it below the step's floor, and the proxy is the one that applies
|
|
726
|
+
* it: all the CLI does is ask.
|
|
727
|
+
*
|
|
728
|
+
* @param {Object} persona
|
|
729
|
+
* @returns {boolean}
|
|
730
|
+
*/
|
|
731
|
+
export function shouldEscalate(persona) {
|
|
732
|
+
const hint = persona && persona.model_tier_hint;
|
|
733
|
+
return hint === 'tier_frontier' || hint === 'tier_apex';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Rebuild the assistant turn for the conversation. Text first, then the
|
|
738
|
+
* tool calls; a malformed call goes back as an empty input so the pairing
|
|
739
|
+
* with its error tool_result stays valid.
|
|
740
|
+
*/
|
|
741
|
+
export function assistantContent(answer) {
|
|
742
|
+
const content = [];
|
|
743
|
+
if (answer.text && answer.text.trim().length > 0) content.push({ type: 'text', text: answer.text });
|
|
744
|
+
for (const toolUse of answer.toolUses) {
|
|
745
|
+
content.push({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input });
|
|
746
|
+
}
|
|
747
|
+
if (content.length === 0) content.push({ type: 'text', text: '(no output)' });
|
|
748
|
+
return content;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Keep the transcript readable without storing a whole screenshot in it. */
|
|
752
|
+
function redactToolInput(toolUse) {
|
|
753
|
+
if (!toolUse.input) return null;
|
|
754
|
+
const { thought_in_character: ignored, ...rest } = toolUse.input;
|
|
755
|
+
return rest;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
759
|
+
try {
|
|
760
|
+
const shot = await driver.screenshot(name);
|
|
761
|
+
screenshots.push({ ...shot, reason: name });
|
|
762
|
+
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
763
|
+
return shot;
|
|
764
|
+
} catch (error) {
|
|
765
|
+
// Headless Godot cannot screenshot at all, which spec 03 says is
|
|
766
|
+
// expected and not a finding. Never let it end a run.
|
|
767
|
+
transcript.write({ kind: 'screenshot_failed', name, message: String(error && error.message) });
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function driverStop(driver, outcome, log) {
|
|
773
|
+
try {
|
|
774
|
+
const stopped = await driver.stop();
|
|
775
|
+
if (stopped && stopped.videoPath) outcome.videoPath = stopped.videoPath;
|
|
776
|
+
// The Godot driver captures frames rather than video, and stitching them
|
|
777
|
+
// needs ffmpeg, which is optional. No video is a normal outcome: say
|
|
778
|
+
// where the frames are and move on.
|
|
779
|
+
if (stopped && !stopped.videoPath && stopped.framesDir) {
|
|
780
|
+
outcome.framesDir = stopped.framesDir;
|
|
781
|
+
log(`no session.webm for this run; the frame sequence is in ${stopped.framesDir} and needs ffmpeg to stitch.`);
|
|
782
|
+
}
|
|
783
|
+
} catch (error) {
|
|
784
|
+
log(`the driver did not shut down cleanly: ${String(error && error.message)}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function transition(api, gameId, jobId, runId, state, reason) {
|
|
789
|
+
// cli-core's transition takes an options object, not a bare reason string.
|
|
790
|
+
return api.runs.transition(gameId, jobId, runId, state, { reason });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
794
|
+
// One last beat before the ledger is written: the answer carries the run's
|
|
795
|
+
// authoritative spend, and prices are server side only, so without this
|
|
796
|
+
// `usage.json` says `unavailable` for a run that ended between two ticks.
|
|
797
|
+
if (heartbeat) {
|
|
798
|
+
try {
|
|
799
|
+
await heartbeat.beat();
|
|
800
|
+
} catch {
|
|
801
|
+
// A beat that fails here costs the dollar figure, not the run.
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
outcome.usage = ledger.toReportUsage();
|
|
805
|
+
await writeUsageFile(runDirectory, ledger);
|
|
806
|
+
transcript.write({ kind: 'end', state: outcome.state, quit_reason: outcome.quitReason, actions: outcome.actionsTaken });
|
|
807
|
+
await transcript.close();
|
|
808
|
+
return outcome;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The markdown written when there is no model turn left to write a real
|
|
813
|
+
* report with: cancelled, or out of budget. It carries the notes and the
|
|
814
|
+
* screenshot list and says plainly that it is partial, rather than
|
|
815
|
+
* inventing a persona verdict nobody gave.
|
|
816
|
+
*/
|
|
817
|
+
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
|
|
818
|
+
const good = notes.filter(note => note.kind === 'good');
|
|
819
|
+
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
820
|
+
const list = entries => (entries.length > 0
|
|
821
|
+
? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
|
|
822
|
+
: '- Nothing was logged.');
|
|
823
|
+
|
|
824
|
+
// The four headings the server lints for are here too, even in a partial
|
|
825
|
+
// report. A partial report is still a report somebody reads, and a lint
|
|
826
|
+
// finding on it would be noise about a run that already went wrong.
|
|
827
|
+
return [
|
|
828
|
+
`# Playtest: ${persona.name || persona.slug} (partial)`,
|
|
829
|
+
`**Driver:** ${driverName} - **Target:** ${target}`,
|
|
830
|
+
`**Session length:** ${play.actionsTaken} actions / ${play.quitReason}`,
|
|
831
|
+
'',
|
|
832
|
+
'## Why this report is partial',
|
|
833
|
+
reason,
|
|
834
|
+
'',
|
|
835
|
+
'## What worked',
|
|
836
|
+
list(good),
|
|
837
|
+
'',
|
|
838
|
+
'## Top issues',
|
|
839
|
+
list(bad),
|
|
840
|
+
'',
|
|
841
|
+
"## What this run didn't cover",
|
|
842
|
+
`The session ended after ${play.actionsTaken} action(s) with ${play.quitReason}, so nothing past that point was played, and no persona verdict was recorded.`,
|
|
843
|
+
'',
|
|
844
|
+
'## Suggested next step',
|
|
845
|
+
'Run this persona again once the reason above is resolved.',
|
|
846
|
+
'',
|
|
847
|
+
'## Evidence',
|
|
848
|
+
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
|
|
849
|
+
''
|
|
850
|
+
].join('\n');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
854
|
+
const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
|
|
855
|
+
assertNoSecrets('report.md', markdown);
|
|
856
|
+
await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The `prompt_version` the report has to carry, read out of the skill's own
|
|
861
|
+
* frontmatter so a content pack update moves it without a CLI release.
|
|
862
|
+
*
|
|
863
|
+
* @param {string} skill
|
|
864
|
+
* @returns {string|null}
|
|
865
|
+
*/
|
|
866
|
+
export function promptVersionOf(skill) {
|
|
867
|
+
const match = String(skill || '').match(/^prompt_version:\s*(\S+)\s*$/m);
|
|
868
|
+
return match ? match[1] : null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown };
|