ravensight-playtest 0.1.0 → 0.2.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.
@@ -586,8 +586,12 @@ export async function playPersonaRun({
586
586
  *
587
587
  * A refused delivery does not take the job down. `finish` still settles it, the
588
588
  * artifacts are on disk, and `resume` can retry.
589
+ *
590
+ * Exported for `fix`, whose verify run is an ordinary persona run and has to be
591
+ * uploaded and completed in exactly this order, including the `uploading`
592
+ * transition the server's table allows from `reporting` and nowhere else.
589
593
  */
590
- async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
594
+ export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
591
595
  const body = {
592
596
  state: outcome.state,
593
597
  reason: outcome.quitDetail || '',
@@ -807,9 +811,14 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
807
811
  * the default behaviour: someone pressing Ctrl-C twice means it now, and
808
812
  * holding the process open to be tidy would be the wrong answer.
809
813
  *
814
+ * `message` is for a command whose work a resume cannot pick up. `run` and
815
+ * `resume` can both be carried on, and say so; `fix` cannot, because its runs
816
+ * are half-applied edits rather than a replayable playthrough, and promising a
817
+ * resume there would send the developer to a command that would refuse them.
818
+ *
810
819
  * @returns {{signalled: boolean, settle: () => Promise<void>, release: () => void}}
811
820
  */
812
- export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, injected = {} }) {
821
+ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, message = null, injected = {} }) {
813
822
  const signals = injected.signals || ['SIGINT', 'SIGTERM'];
814
823
  const target = injected.process || process;
815
824
  const state = { signalled: false, signal: null };
@@ -818,7 +827,7 @@ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal,
818
827
  if (state.signalled) return;
819
828
  state.signalled = true;
820
829
  state.signal = signal;
821
- log(`\n${signal}: finishing the turn in flight, then stopping. Nothing is lost; "ravensight-playtest resume ${jobId}" carries on.`);
830
+ log(`\n${signal}: ${message || `finishing the turn in flight, then stopping. Nothing is lost; "ravensight-playtest resume ${jobId}" carries on.`}`);
822
831
  // Hand the signal back to the default behaviour, so a second press kills
823
832
  // the process rather than being swallowed by a handler that is now done.
824
833
  for (const name of signals) target.removeListener(name, onSignal);
@@ -117,10 +117,25 @@ export async function sync({ api, gameId, modules }) {
117
117
  * An authentication or authorization failure is never treated as "offline":
118
118
  * falling back to a cache on a 401 would let a revoked token keep working.
119
119
  *
120
- * @param {{api: Object, gameId?: string, modules?: string[], offline?: boolean}} options
120
+ * A `jobId` asks for the bundle in one job's context, which is the only way
121
+ * `graph_context` is populated. That answer is about one job and can never
122
+ * serve another, so it is fetched straight through: caching it would put a file
123
+ * per job in the cache directory that nothing would ever read again.
124
+ *
125
+ * @param {{api: Object, gameId?: string, modules?: string[], jobId?: string, offline?: boolean}} options
121
126
  * @returns {Promise<PackResult>}
122
127
  */
123
- export async function get({ api, gameId, modules, offline = false }) {
128
+ export async function get({ api, gameId, modules, jobId = null, offline = false }) {
129
+ if (jobId) {
130
+ const answer = await api.packs.bundle({ gameId, modules, jobId });
131
+ return {
132
+ bundle: answer.body,
133
+ etag: answer.etag || null,
134
+ fromCache: false,
135
+ fetchedAt: new Date().toISOString(),
136
+ stale: false
137
+ };
138
+ }
124
139
  const key = { apiUrl: api.apiUrl, gameId, modules };
125
140
  const existing = await cached(key);
126
141
 
package/src/run/args.js CHANGED
@@ -97,7 +97,12 @@ export function optionsFrom(flags = {}) {
97
97
  idempotencyKey: flags.idempotencyKey,
98
98
  // `resume` declares this one in commander rather than leaving it to the
99
99
  // argv leftovers, so it arrives as a flag and has to be read here too.
100
- restartInterrupted: flags.restartInterrupted
100
+ restartInterrupted: flags.restartInterrupted,
101
+ // `fix` declares these three in commander for the same reason: they are
102
+ // about the branch and the verify, not about driving a game.
103
+ branch: flags.branch,
104
+ allowDirty: flags.allowDirty,
105
+ verify: flags.verify
101
106
  })
102
107
  },
103
108
  unknown
@@ -0,0 +1,430 @@
1
+ import { parseJsonObject } from './json.js';
2
+ import { callStepWithRetry, ProxyError, STEPS } from './model.js';
3
+ import { createRepoTools, WRITE_TOOLS } from './repoTools.js';
4
+
5
+ /**
6
+ * The `fix` module's three model steps: plan, apply, summarise.
7
+ *
8
+ * They are separate steps rather than one loop because they are priced and
9
+ * tooled differently, and because the boundaries are where the safety is:
10
+ *
11
+ * - `fix.plan` reads the finding and answers a plan. One call, plus at most
12
+ * one repair turn when the answer is not JSON. It cannot write, so a plan
13
+ * that is wrong costs a turn rather than a file.
14
+ * - `fix.apply` is the loop, and the only step anywhere in this CLI whose
15
+ * tools include `write_file`. Its ceiling is the action budget, and its
16
+ * guards are in ./repoTools.js rather than in the prompt.
17
+ * - `fix.summarize` has no tools at all. It reads the diff that was actually
18
+ * produced and writes the pull request text, so the summary describes the
19
+ * change on disk instead of the change that was planned.
20
+ *
21
+ * What none of them can do: run a command, reach the network, read a
22
+ * credential file, write outside the repository, or publish anything. The
23
+ * developer's own review is the gate between this and a pull request, which
24
+ * is why the command ends by printing a command rather than running one.
25
+ */
26
+
27
+ /** Tokens asked for per step. The proxy clamps each to the step's own ceiling. */
28
+ export const PLAN_MAX_TOKENS = 2048;
29
+ /** Higher than the others: a `write_file` call carries a whole file. */
30
+ export const APPLY_MAX_TOKENS = 8192;
31
+ export const SUMMARY_MAX_TOKENS = 4096;
32
+
33
+ /**
34
+ * The hard ceiling on apply turns, whatever the pack or the developer asks
35
+ * for. A fix that has not landed in sixty turns is not one turn away, and
36
+ * every turn is billable.
37
+ */
38
+ export const MAX_FIX_ACTIONS = 60;
39
+
40
+ /** A plan that says nothing, which is what a malformed answer becomes. */
41
+ export const EMPTY_PLAN = Object.freeze({ hypothesis: '', files_to_change: [], out_of_scope: [] });
42
+
43
+ /**
44
+ * The plan step's own image ceiling. The proxy counts image blocks across the
45
+ * whole `messages` array and answers 400 `too_many_images` past it, so this
46
+ * is a ceiling on the context rather than on one turn.
47
+ */
48
+ export const MAX_PLAN_IMAGES = 2;
49
+
50
+ /**
51
+ * The action budget for `fix.apply`: the smallest of what the developer
52
+ * asked for and what routing allows, and never more than `MAX_FIX_ACTIONS`.
53
+ *
54
+ * @param {Object} options
55
+ * @param {Object} pack
56
+ * @returns {number}
57
+ */
58
+ export function resolveFixMaxActions(options = {}, pack = {}) {
59
+ const candidates = [
60
+ Number.isInteger(options.maxActions) ? options.maxActions : null,
61
+ Number(pack && pack.routing && pack.routing.max_actions) || null
62
+ ].filter(value => Number.isFinite(value) && value > 0);
63
+ const asked = candidates.length > 0 ? Math.min(...candidates) : MAX_FIX_ACTIONS;
64
+ return Math.max(1, Math.min(asked, MAX_FIX_ACTIONS));
65
+ }
66
+
67
+ /**
68
+ * The system prompt every fix step shares: the pack's `fix` skill, then the
69
+ * facts about this repository and this attempt.
70
+ *
71
+ * @param {Object} args
72
+ * @returns {Array<Object>}
73
+ */
74
+ export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
75
+ return [
76
+ { type: 'text', text: skill },
77
+ {
78
+ type: 'text',
79
+ text: [
80
+ `job_id: ${jobId}`,
81
+ 'the paths you read and write are relative to the repository root; never absolute',
82
+ `engine recorded on this game: ${engine || 'unknown'}`,
83
+ `branch: ${branch} (created for this attempt, from ${baseCommit || 'unknown'})`,
84
+ 'you cannot run commands, reach the network, or commit anything. The developer reviews the branch and opens the pull request themselves.'
85
+ ].join('\n'),
86
+ cache_control: { type: 'ephemeral' }
87
+ }
88
+ ];
89
+ }
90
+
91
+ /**
92
+ * The bundle's map of the codebase, as content blocks.
93
+ *
94
+ * It goes in the user message of the two steps that read code rather than in
95
+ * the shared system block, so neither step pays for the other's copy and the
96
+ * summarise step, which reads no code at all, pays for none of it.
97
+ *
98
+ * @param {Object|null} codeBrief
99
+ * @param {Object|null} graphContext
100
+ * @returns {Array<string>} lines
101
+ */
102
+ export function bundleLines(codeBrief, graphContext) {
103
+ const lines = [];
104
+ if (codeBrief) lines.push(`<code_brief>\n${JSON.stringify(codeBrief).slice(0, 4000)}\n</code_brief>`);
105
+ if (graphContext) lines.push(`<graph_context>\n${JSON.stringify(graphContext).slice(0, 4000)}\n</graph_context>`);
106
+ return lines;
107
+ }
108
+
109
+ /**
110
+ * The user message the plan step reads: what was found, how to see it, what
111
+ * the persona said, up to two screenshots, and what is already known to be
112
+ * intended.
113
+ *
114
+ * @param {Object} args
115
+ * @returns {Array<Object>} content blocks
116
+ */
117
+ export function planContent({
118
+ finding,
119
+ reproSteps = [],
120
+ personaQuote = null,
121
+ images = [],
122
+ intentNotes = [],
123
+ reportMarkdown = null,
124
+ codeBrief = null,
125
+ graphContext = null
126
+ }) {
127
+ const lines = [
128
+ 'A persona filed this finding while playing the game. Work out why it happens and plan the smallest change that fixes it.',
129
+ '',
130
+ `<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
131
+ `<repro_steps>\n${reproSteps.length > 0 ? reproSteps.map((step, index) => `${index + 1}. ${step}`).join('\n') : 'none recorded'}\n</repro_steps>`,
132
+ `<persona_quote>\n${personaQuote || 'none recorded'}\n</persona_quote>`,
133
+ `<design_intent_notes>\n${intentNotes.length > 0 ? intentNotes.map(note => `- ${note}`).join('\n') : 'none'}\n</design_intent_notes>`,
134
+ ...bundleLines(codeBrief, graphContext)
135
+ ];
136
+ if (reportMarkdown) lines.push(`<run_report>\n${String(reportMarkdown).slice(0, 12000)}\n</run_report>`);
137
+ lines.push(
138
+ '',
139
+ 'Answer with this JSON object and nothing else:',
140
+ '{ "hypothesis": "one or two sentences on the cause", "files_to_change": ["repo/relative/path"], "out_of_scope": ["what you are deliberately not touching"] }'
141
+ );
142
+ return [
143
+ { type: 'text', text: lines.join('\n') },
144
+ ...images.slice(0, MAX_PLAN_IMAGES).map(image => ({
145
+ type: 'image',
146
+ source: { type: 'base64', media_type: image.mediaType || 'image/png', data: image.base64 }
147
+ }))
148
+ ];
149
+ }
150
+
151
+ /**
152
+ * Plan the fix: one call, and one repair turn when the answer is not a JSON
153
+ * object.
154
+ *
155
+ * The step's tool allowlist is the read-only three, so the tools are offered
156
+ * and a model that would rather read a file first can. Tool calls are
157
+ * answered properly in the repair turn, which keeps the message sequence
158
+ * legal and gets the reading it asked for; it is still one repair, never a
159
+ * loop, because planning is not where the budget belongs.
160
+ *
161
+ * @param {Object} args
162
+ * @returns {Promise<{plan: Object, turns: number, repaired: boolean, messages: Array}>}
163
+ */
164
+ export async function planFix({
165
+ client,
166
+ jobId,
167
+ runId,
168
+ system,
169
+ content,
170
+ repo,
171
+ ledger = null,
172
+ log = () => {}
173
+ }) {
174
+ const messages = [{ role: 'user', content }];
175
+ let turns = 0;
176
+ let repaired = false;
177
+ let plan = null;
178
+
179
+ for (let attempt = 1; attempt <= 2 && !plan; attempt += 1) {
180
+ const answer = await callStepWithRetry({
181
+ client,
182
+ jobId,
183
+ runId,
184
+ step: STEPS.fixPlan,
185
+ system,
186
+ messages,
187
+ tools: repo ? repo.tools : undefined,
188
+ maxTokens: PLAN_MAX_TOKENS
189
+ });
190
+ turns += 1;
191
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
192
+ if (answer.budget === 'warn') log('budget warning: this fix has used at least 80 percent of its model budget.');
193
+
194
+ const parsed = parseJsonObject(answer.text);
195
+ if (parsed) {
196
+ plan = normalisePlan(parsed);
197
+ break;
198
+ }
199
+ if (attempt === 2) break;
200
+
201
+ repaired = true;
202
+ messages.push({
203
+ role: 'assistant',
204
+ content: [
205
+ ...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
206
+ ...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
207
+ ]
208
+ });
209
+ const results = [];
210
+ for (const toolUse of answer.toolUses) {
211
+ if (!repo) break;
212
+ const executed = await repo.execute(toolUse);
213
+ results.push(executed.result);
214
+ }
215
+ messages.push({
216
+ role: 'user',
217
+ content: [
218
+ ...results,
219
+ { type: 'text', text: 'That was not the plan object. Answer with the JSON object only, nothing before or after it.' }
220
+ ]
221
+ });
222
+ }
223
+
224
+ if (!plan) log('The plan did not come back as JSON after one repair, so the fix proceeds without one.');
225
+ return { plan: plan || { ...EMPTY_PLAN }, turns, repaired, messages };
226
+ }
227
+
228
+ /**
229
+ * @param {Object} parsed
230
+ * @returns {{hypothesis: string, files_to_change: Array<string>, out_of_scope: Array<string>}}
231
+ */
232
+ export function normalisePlan(parsed) {
233
+ const paths = Array.isArray(parsed.files_to_change) ? parsed.files_to_change : [];
234
+ return {
235
+ hypothesis: typeof parsed.hypothesis === 'string' ? parsed.hypothesis : '',
236
+ files_to_change: paths
237
+ .map(entry => (typeof entry === 'string' ? entry : entry && entry.path))
238
+ .filter(entry => typeof entry === 'string' && entry.trim())
239
+ .map(entry => entry.trim()),
240
+ out_of_scope: (Array.isArray(parsed.out_of_scope) ? parsed.out_of_scope : [])
241
+ .map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
242
+ .filter(Boolean)
243
+ };
244
+ }
245
+
246
+ /**
247
+ * The apply loop: read, write, repeat, until the model says it is done, the
248
+ * action budget runs out, or the run's model budget does.
249
+ *
250
+ * A 402 is not an error here. The files already written are on the branch and
251
+ * are what the developer will review, so the loop stops, says so, and the
252
+ * command carries on to report what exists.
253
+ *
254
+ * @param {Object} args
255
+ * @returns {Promise<{turns: number, written: Array<string>, stop: string}>}
256
+ */
257
+ export async function applyFix({
258
+ client,
259
+ jobId,
260
+ runId,
261
+ repoRoot,
262
+ system,
263
+ plan,
264
+ maxActions,
265
+ codeBrief = null,
266
+ graphContext = null,
267
+ repo = createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }),
268
+ ledger = null,
269
+ log = () => {},
270
+ onProgress = () => {},
271
+ shouldStop = () => false
272
+ }) {
273
+ const messages = [{
274
+ role: 'user',
275
+ content: [{
276
+ type: 'text',
277
+ text: [
278
+ 'Make the change now. Read before you write: `write_file` replaces the whole file, so send the file back with your edit in it.',
279
+ 'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.',
280
+ '',
281
+ `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
282
+ ...bundleLines(codeBrief, graphContext)
283
+ ].join('\n')
284
+ }]
285
+ }];
286
+
287
+ let turns = 0;
288
+ let stop = 'max_actions';
289
+ while (turns < maxActions) {
290
+ if (shouldStop()) {
291
+ stop = 'interrupted';
292
+ break;
293
+ }
294
+ turns += 1;
295
+ let answer;
296
+ try {
297
+ answer = await callStepWithRetry({
298
+ client,
299
+ jobId,
300
+ runId,
301
+ step: STEPS.fixApply,
302
+ system,
303
+ messages,
304
+ tools: repo.tools,
305
+ maxTokens: APPLY_MAX_TOKENS
306
+ });
307
+ } catch (error) {
308
+ if (error instanceof ProxyError && error.isBudget) {
309
+ log('This fix has spent its model budget. Whatever was written is on the branch.');
310
+ stop = 'budget';
311
+ break;
312
+ }
313
+ throw error;
314
+ }
315
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
316
+
317
+ if (answer.toolUses.length === 0) {
318
+ messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text || '(done)' }] });
319
+ stop = 'end_turn';
320
+ break;
321
+ }
322
+
323
+ messages.push({
324
+ role: 'assistant',
325
+ content: [
326
+ ...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
327
+ ...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
328
+ ]
329
+ });
330
+ const results = [];
331
+ for (const toolUse of answer.toolUses) {
332
+ const executed = await repo.execute(toolUse);
333
+ results.push(executed.result);
334
+ onProgress({ turn: turns, tool: toolUse.name, target: toolUse.input && (toolUse.input.path || toolUse.input.pattern) });
335
+ }
336
+ messages.push({ role: 'user', content: results });
337
+
338
+ if (answer.budget === 'warn') {
339
+ log('budget warning: this fix has used at least 80 percent of its model budget. Wrapping up.');
340
+ stop = 'budget_warning';
341
+ break;
342
+ }
343
+ }
344
+
345
+ return { turns, written: repo.written, stop };
346
+ }
347
+
348
+ /**
349
+ * The pull request text, written from the diff that exists rather than from
350
+ * the plan that was made.
351
+ *
352
+ * `fallback` is used when the step cannot answer at all (no budget left, or
353
+ * an answer with no JSON in it). A fix that landed on disk still has to be
354
+ * reportable, and a locally written summary that says so is more honest than
355
+ * failing the whole attempt over its prose.
356
+ *
357
+ * @param {Object} args
358
+ * @returns {Promise<{prTitle: string, prBody: string, source: string}>}
359
+ */
360
+ export async function summariseFix({
361
+ client,
362
+ jobId,
363
+ runId,
364
+ system,
365
+ finding,
366
+ plan,
367
+ files,
368
+ verify,
369
+ branch,
370
+ fallback,
371
+ ledger = null,
372
+ log = () => {}
373
+ }) {
374
+ const content = [{
375
+ type: 'text',
376
+ text: [
377
+ 'Write the pull request text for the change you just made. The reviewer is the developer who owns this code and has not seen the finding.',
378
+ '',
379
+ `<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
380
+ `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
381
+ `<diff_stat>\n${files.map(file => `${file.path}: +${file.additions} -${file.deletions}`).join('\n') || 'nothing'}\n</diff_stat>`,
382
+ `<verify>\n${verify ? JSON.stringify(verify) : 'the fix was not verified by replaying the finding'}\n</verify>`,
383
+ `<branch>${branch}</branch>`,
384
+ '',
385
+ 'Answer with this JSON object and nothing else:',
386
+ '{ "pr_title": "one line, at most 120 characters", "pr_body": "markdown: what was wrong, what changed, what the verify run showed, and what the reviewer should check by hand" }'
387
+ ].join('\n')
388
+ }];
389
+
390
+ let answer;
391
+ try {
392
+ answer = await callStepWithRetry({
393
+ client,
394
+ jobId,
395
+ runId,
396
+ step: STEPS.fixSummarize,
397
+ system,
398
+ messages: [{ role: 'user', content }],
399
+ maxTokens: SUMMARY_MAX_TOKENS
400
+ });
401
+ } catch (error) {
402
+ if (error instanceof ProxyError && error.isBudget) {
403
+ log('There was no budget left to write the pull request text, so it was written locally instead.');
404
+ return { ...fallback, source: 'local' };
405
+ }
406
+ throw error;
407
+ }
408
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
409
+
410
+ const parsed = parseJsonObject(answer.text) || {};
411
+ const title = typeof parsed.pr_title === 'string' && parsed.pr_title.trim() ? parsed.pr_title.trim() : fallback.prTitle;
412
+ const body = typeof parsed.pr_body === 'string' && parsed.pr_body.trim() ? parsed.pr_body : fallback.prBody;
413
+ return {
414
+ prTitle: clamp(title, 120),
415
+ prBody: clamp(body, 20000),
416
+ source: parsed.pr_title || parsed.pr_body ? 'model' : 'local'
417
+ };
418
+ }
419
+
420
+ /**
421
+ * @param {string} value
422
+ * @param {number} limit
423
+ * @returns {string}
424
+ */
425
+ export function clamp(value, limit) {
426
+ const text = String(value || '');
427
+ return text.length <= limit ? text : text.slice(0, limit);
428
+ }
429
+
430
+ export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan };