ravensight-playtest 0.1.0 → 0.2.3

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);
package/src/dashboard.js CHANGED
@@ -36,24 +36,34 @@ export function dashboardUrl(apiUrl = resolveApiUrl()) {
36
36
  return url.toString().replace(/\/+$/, '');
37
37
  }
38
38
 
39
+ /**
40
+ * The dashboard's playtest routes carry no game in the path: the switcher
41
+ * holds the game, and a link names it in the query so the router can select
42
+ * it on arrival. `/games/<id>/playtest/...` was the first cut and answered
43
+ * "That page does not exist" for every link this CLI printed.
44
+ */
45
+ function playtestPage(gameId, page, apiUrl) {
46
+ return `${dashboardUrl(apiUrl)}/playtest${page}?game=${encodeURIComponent(gameId)}`;
47
+ }
48
+
39
49
  /** The dashboard page for one game's playtest surface. */
40
50
  export function gameLink(gameId, apiUrl) {
41
- return `${dashboardUrl(apiUrl)}/games/${encodeURIComponent(gameId)}/playtest`;
51
+ return playtestPage(gameId, '', apiUrl);
42
52
  }
43
53
 
44
54
  /** The dashboard page for one job. Matches `links.dashboard` from POST /jobs. */
45
55
  export function jobLink(gameId, jobId, apiUrl) {
46
- return `${gameLink(gameId, apiUrl)}/jobs/${encodeURIComponent(jobId)}`;
56
+ return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}`, apiUrl);
47
57
  }
48
58
 
49
59
  /** The post job review form for one job. */
50
60
  export function reviewLink(gameId, jobId, apiUrl) {
51
- return `${jobLink(gameId, jobId, apiUrl)}/review`;
61
+ return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}/review`, apiUrl);
52
62
  }
53
63
 
54
64
  /** The brief form. */
55
65
  export function briefLink(gameId, apiUrl) {
56
- return `${gameLink(gameId, apiUrl)}/brief`;
66
+ return playtestPage(gameId, '/brief', apiUrl);
57
67
  }
58
68
 
59
69
  /**
@@ -63,7 +73,7 @@ export function briefLink(gameId, apiUrl) {
63
73
  * server can keep true.
64
74
  */
65
75
  export function trustLink(gameId, apiUrl) {
66
- return `${gameLink(gameId, apiUrl)}/what-leaves-your-machine`;
76
+ return playtestPage(gameId, '/what-leaves-your-machine', apiUrl);
67
77
  }
68
78
 
69
79
  /**
@@ -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,489 @@
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
+ /**
41
+ * How many tool-answering turns the plan step may take before it has to
42
+ * answer. A real codebase takes several reads to locate a behavior; one
43
+ * repair turn (the first cut) produced empty plans on every real repository.
44
+ */
45
+ export const PLAN_MAX_TURNS = 6;
46
+
47
+ /**
48
+ * Every this-many apply turns without a write, the loop tells the model how
49
+ * many actions are left and asks it to write or say what blocks it. A model
50
+ * that is lost otherwise reads until the budget warning ends the run.
51
+ */
52
+ export const NUDGE_EVERY = 8;
53
+
54
+ /** The text of a tool result, whichever of the two content shapes it uses. */
55
+ function resultText(result) {
56
+ if (!result) return '';
57
+ if (typeof result.content === 'string') return result.content;
58
+ if (Array.isArray(result.content)) return result.content.map(block => (block && block.text) || '').join('');
59
+ return '';
60
+ }
61
+
62
+ function resultLength(result) {
63
+ return resultText(result).length;
64
+ }
65
+
66
+ /** A plan that says nothing, which is what a malformed answer becomes. */
67
+ export const EMPTY_PLAN = Object.freeze({ hypothesis: '', files_to_change: [], out_of_scope: [] });
68
+
69
+ /**
70
+ * The plan step's own image ceiling. The proxy counts image blocks across the
71
+ * whole `messages` array and answers 400 `too_many_images` past it, so this
72
+ * is a ceiling on the context rather than on one turn.
73
+ */
74
+ export const MAX_PLAN_IMAGES = 2;
75
+
76
+ /**
77
+ * The action budget for `fix.apply`: the smallest of what the developer
78
+ * asked for and what routing allows, and never more than `MAX_FIX_ACTIONS`.
79
+ *
80
+ * @param {Object} options
81
+ * @param {Object} pack
82
+ * @returns {number}
83
+ */
84
+ export function resolveFixMaxActions(options = {}, pack = {}) {
85
+ const candidates = [
86
+ Number.isInteger(options.maxActions) ? options.maxActions : null,
87
+ Number(pack && pack.routing && pack.routing.max_actions) || null
88
+ ].filter(value => Number.isFinite(value) && value > 0);
89
+ const asked = candidates.length > 0 ? Math.min(...candidates) : MAX_FIX_ACTIONS;
90
+ return Math.max(1, Math.min(asked, MAX_FIX_ACTIONS));
91
+ }
92
+
93
+ /**
94
+ * The system prompt every fix step shares: the pack's `fix` skill, then the
95
+ * facts about this repository and this attempt.
96
+ *
97
+ * @param {Object} args
98
+ * @returns {Array<Object>}
99
+ */
100
+ export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
101
+ return [
102
+ { type: 'text', text: skill },
103
+ {
104
+ type: 'text',
105
+ text: [
106
+ `job_id: ${jobId}`,
107
+ 'the paths you read and write are relative to the repository root; never absolute',
108
+ `engine recorded on this game: ${engine || 'unknown'}`,
109
+ `branch: ${branch} (created for this attempt, from ${baseCommit || 'unknown'})`,
110
+ 'you cannot run commands, reach the network, or commit anything. The developer reviews the branch and opens the pull request themselves.'
111
+ ].join('\n'),
112
+ cache_control: { type: 'ephemeral' }
113
+ }
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * The bundle's map of the codebase, as content blocks.
119
+ *
120
+ * It goes in the user message of the two steps that read code rather than in
121
+ * the shared system block, so neither step pays for the other's copy and the
122
+ * summarise step, which reads no code at all, pays for none of it.
123
+ *
124
+ * @param {Object|null} codeBrief
125
+ * @param {Object|null} graphContext
126
+ * @returns {Array<string>} lines
127
+ */
128
+ export function bundleLines(codeBrief, graphContext) {
129
+ const lines = [];
130
+ if (codeBrief) lines.push(`<code_brief>\n${JSON.stringify(codeBrief).slice(0, 4000)}\n</code_brief>`);
131
+ if (graphContext) lines.push(`<graph_context>\n${JSON.stringify(graphContext).slice(0, 4000)}\n</graph_context>`);
132
+ return lines;
133
+ }
134
+
135
+ /**
136
+ * The user message the plan step reads: what was found, how to see it, what
137
+ * the persona said, up to two screenshots, and what is already known to be
138
+ * intended.
139
+ *
140
+ * @param {Object} args
141
+ * @returns {Array<Object>} content blocks
142
+ */
143
+ export function planContent({
144
+ finding,
145
+ reproSteps = [],
146
+ personaQuote = null,
147
+ images = [],
148
+ intentNotes = [],
149
+ reportMarkdown = null,
150
+ codeBrief = null,
151
+ graphContext = null
152
+ }) {
153
+ const lines = [
154
+ 'A persona filed this finding while playing the game. Work out why it happens and plan the smallest change that fixes it.',
155
+ '',
156
+ `<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
157
+ `<repro_steps>\n${reproSteps.length > 0 ? reproSteps.map((step, index) => `${index + 1}. ${step}`).join('\n') : 'none recorded'}\n</repro_steps>`,
158
+ `<persona_quote>\n${personaQuote || 'none recorded'}\n</persona_quote>`,
159
+ `<design_intent_notes>\n${intentNotes.length > 0 ? intentNotes.map(note => `- ${note}`).join('\n') : 'none'}\n</design_intent_notes>`,
160
+ ...bundleLines(codeBrief, graphContext)
161
+ ];
162
+ if (reportMarkdown) lines.push(`<run_report>\n${String(reportMarkdown).slice(0, 12000)}\n</run_report>`);
163
+ lines.push(
164
+ '',
165
+ 'Answer with this JSON object and nothing else:',
166
+ '{ "hypothesis": "one or two sentences on the cause", "files_to_change": ["repo/relative/path"], "out_of_scope": ["what you are deliberately not touching"] }'
167
+ );
168
+ return [
169
+ { type: 'text', text: lines.join('\n') },
170
+ ...images.slice(0, MAX_PLAN_IMAGES).map(image => ({
171
+ type: 'image',
172
+ source: { type: 'base64', media_type: image.mediaType || 'image/png', data: image.base64 }
173
+ }))
174
+ ];
175
+ }
176
+
177
+ /**
178
+ * Plan the fix: one call, and one repair turn when the answer is not a JSON
179
+ * object.
180
+ *
181
+ * The step's tool allowlist is the read-only three, so the tools are offered
182
+ * and a model that would rather read a file first can. Tool calls are
183
+ * answered properly in the repair turn, which keeps the message sequence
184
+ * legal and gets the reading it asked for; it is still one repair, never a
185
+ * loop, because planning is not where the budget belongs.
186
+ *
187
+ * @param {Object} args
188
+ * @returns {Promise<{plan: Object, turns: number, repaired: boolean, messages: Array}>}
189
+ */
190
+ export async function planFix({
191
+ client,
192
+ jobId,
193
+ runId,
194
+ system,
195
+ content,
196
+ repo,
197
+ ledger = null,
198
+ log = () => {}
199
+ }) {
200
+ const messages = [{ role: 'user', content }];
201
+ let turns = 0;
202
+ let repaired = false;
203
+ let plan = null;
204
+
205
+ // Reading turns are answered and counted; a turn with no tool calls is
206
+ // the answer, and one repair is allowed when that answer is not JSON. At
207
+ // the ceiling the model is told to answer now, and if it still reaches for
208
+ // a tool the plan stays empty: planning is not where the budget belongs.
209
+ while (turns < PLAN_MAX_TURNS + 1 && !plan) {
210
+ const answer = await callStepWithRetry({
211
+ client,
212
+ jobId,
213
+ runId,
214
+ step: STEPS.fixPlan,
215
+ system,
216
+ messages,
217
+ tools: repo ? repo.tools : undefined,
218
+ maxTokens: PLAN_MAX_TOKENS
219
+ });
220
+ turns += 1;
221
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
222
+ if (answer.budget === 'warn') log('budget warning: this fix has used at least 80 percent of its model budget.');
223
+
224
+ const parsed = parseJsonObject(answer.text);
225
+ if (parsed) {
226
+ plan = normalisePlan(parsed);
227
+ break;
228
+ }
229
+ const reading = answer.toolUses.length > 0 && Boolean(repo);
230
+ if (!reading) {
231
+ if (repaired) break;
232
+ repaired = true;
233
+ }
234
+ if (turns > PLAN_MAX_TURNS) break;
235
+
236
+ messages.push({
237
+ role: 'assistant',
238
+ content: [
239
+ ...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
240
+ ...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
241
+ ]
242
+ });
243
+ const results = [];
244
+ for (const toolUse of answer.toolUses) {
245
+ if (!repo) break;
246
+ const executed = await repo.execute(toolUse);
247
+ results.push(executed.result);
248
+ }
249
+ const nudge = turns >= PLAN_MAX_TURNS
250
+ ? 'No more reading. Answer now with the JSON object only, nothing before or after it.'
251
+ : reading
252
+ ? `${PLAN_MAX_TURNS - turns} more reading turn(s) before you have to answer with the JSON object.`
253
+ : 'That was not the plan object. Answer with the JSON object only, nothing before or after it.';
254
+ messages.push({ role: 'user', content: [...results, { type: 'text', text: nudge }] });
255
+ }
256
+
257
+ if (!plan) log(`The plan did not come back as JSON within ${PLAN_MAX_TURNS} reading turns and one repair, so the fix proceeds without one.`);
258
+ return { plan: plan || { ...EMPTY_PLAN }, turns, repaired, messages };
259
+ }
260
+
261
+ /**
262
+ * @param {Object} parsed
263
+ * @returns {{hypothesis: string, files_to_change: Array<string>, out_of_scope: Array<string>}}
264
+ */
265
+ export function normalisePlan(parsed) {
266
+ const paths = Array.isArray(parsed.files_to_change) ? parsed.files_to_change : [];
267
+ return {
268
+ hypothesis: typeof parsed.hypothesis === 'string' ? parsed.hypothesis : '',
269
+ files_to_change: paths
270
+ .map(entry => (typeof entry === 'string' ? entry : entry && entry.path))
271
+ .filter(entry => typeof entry === 'string' && entry.trim())
272
+ .map(entry => entry.trim()),
273
+ out_of_scope: (Array.isArray(parsed.out_of_scope) ? parsed.out_of_scope : [])
274
+ .map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
275
+ .filter(Boolean)
276
+ };
277
+ }
278
+
279
+ /**
280
+ * The apply loop: read, write, repeat, until the model says it is done, the
281
+ * action budget runs out, or the run's model budget does.
282
+ *
283
+ * A 402 is not an error here. The files already written are on the branch and
284
+ * are what the developer will review, so the loop stops, says so, and the
285
+ * command carries on to report what exists.
286
+ *
287
+ * @param {Object} args
288
+ * @returns {Promise<{turns: number, written: Array<string>, stop: string}>}
289
+ */
290
+ export async function applyFix({
291
+ client,
292
+ jobId,
293
+ runId,
294
+ repoRoot,
295
+ system,
296
+ plan,
297
+ maxActions,
298
+ codeBrief = null,
299
+ graphContext = null,
300
+ repo = createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }),
301
+ ledger = null,
302
+ log = () => {},
303
+ onProgress = () => {},
304
+ shouldStop = () => false
305
+ }) {
306
+ const messages = [{
307
+ role: 'user',
308
+ content: [{
309
+ type: 'text',
310
+ text: [
311
+ 'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
312
+ '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.',
313
+ '',
314
+ `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
315
+ ...bundleLines(codeBrief, graphContext)
316
+ ].join('\n')
317
+ }]
318
+ }];
319
+
320
+ let turns = 0;
321
+ let stop = 'max_actions';
322
+ // Every call the loop has already answered, so a lost model that asks the
323
+ // same question twice is told so instead of paying for the same answer.
324
+ const answered = new Map();
325
+ let turnsSinceWrite = 0;
326
+ while (turns < maxActions) {
327
+ if (shouldStop()) {
328
+ stop = 'interrupted';
329
+ break;
330
+ }
331
+ turns += 1;
332
+ let answer;
333
+ try {
334
+ answer = await callStepWithRetry({
335
+ client,
336
+ jobId,
337
+ runId,
338
+ step: STEPS.fixApply,
339
+ system,
340
+ messages,
341
+ tools: repo.tools,
342
+ maxTokens: APPLY_MAX_TOKENS
343
+ });
344
+ } catch (error) {
345
+ if (error instanceof ProxyError && error.isBudget) {
346
+ log('This fix has spent its model budget. Whatever was written is on the branch.');
347
+ stop = 'budget';
348
+ break;
349
+ }
350
+ throw error;
351
+ }
352
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
353
+
354
+ if (answer.toolUses.length === 0) {
355
+ messages.push({ role: 'assistant', content: [{ type: 'text', text: answer.text || '(done)' }] });
356
+ stop = 'end_turn';
357
+ break;
358
+ }
359
+
360
+ messages.push({
361
+ role: 'assistant',
362
+ content: [
363
+ ...(answer.text.trim() ? [{ type: 'text', text: answer.text }] : []),
364
+ ...answer.toolUses.map(toolUse => ({ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.malformed ? {} : toolUse.input }))
365
+ ]
366
+ });
367
+ const results = [];
368
+ let wroteThisTurn = false;
369
+ for (const toolUse of answer.toolUses) {
370
+ const key = `${toolUse.name}:${JSON.stringify(toolUse.input || {})}`;
371
+ const repeat = answered.get(key);
372
+ const executed = await repo.execute(toolUse);
373
+ const result = repeat === undefined
374
+ ? executed.result
375
+ : { ...executed.result, content: [{ type: 'text', text: `(identical to a call you already made on turn ${repeat}; the answer has not changed)\n${resultText(executed.result)}` }] };
376
+ answered.set(key, repeat === undefined ? turns : repeat);
377
+ results.push(result);
378
+ if ((toolUse.name === 'write_file' || toolUse.name === 'edit_file') && !executed.result.is_error) wroteThisTurn = true;
379
+ onProgress({
380
+ turn: turns,
381
+ tool: toolUse.name,
382
+ target: toolUse.input && (toolUse.input.path || toolUse.input.pattern),
383
+ resultChars: resultLength(executed.result),
384
+ isError: Boolean(executed.result.is_error),
385
+ repeat: repeat !== undefined
386
+ });
387
+ }
388
+ turnsSinceWrite = wroteThisTurn ? 0 : turnsSinceWrite + 1;
389
+ if (turnsSinceWrite > 0 && turnsSinceWrite % NUDGE_EVERY === 0 && turns < maxActions) {
390
+ results.push({
391
+ type: 'text',
392
+ text: `You have used ${turns} of ${maxActions} actions and written nothing. Either make the change now with edit_file (or write_file for a new file), or answer in one line, with no tool call, why it cannot be made.`
393
+ });
394
+ }
395
+ messages.push({ role: 'user', content: results });
396
+
397
+ if (answer.budget === 'warn') {
398
+ log('budget warning: this fix has used at least 80 percent of its model budget. Wrapping up.');
399
+ stop = 'budget_warning';
400
+ break;
401
+ }
402
+ }
403
+
404
+ return { turns, written: repo.written, stop };
405
+ }
406
+
407
+ /**
408
+ * The pull request text, written from the diff that exists rather than from
409
+ * the plan that was made.
410
+ *
411
+ * `fallback` is used when the step cannot answer at all (no budget left, or
412
+ * an answer with no JSON in it). A fix that landed on disk still has to be
413
+ * reportable, and a locally written summary that says so is more honest than
414
+ * failing the whole attempt over its prose.
415
+ *
416
+ * @param {Object} args
417
+ * @returns {Promise<{prTitle: string, prBody: string, source: string}>}
418
+ */
419
+ export async function summariseFix({
420
+ client,
421
+ jobId,
422
+ runId,
423
+ system,
424
+ finding,
425
+ plan,
426
+ files,
427
+ verify,
428
+ branch,
429
+ fallback,
430
+ ledger = null,
431
+ log = () => {}
432
+ }) {
433
+ const content = [{
434
+ type: 'text',
435
+ text: [
436
+ '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.',
437
+ '',
438
+ `<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
439
+ `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
440
+ `<diff_stat>\n${files.map(file => `${file.path}: +${file.additions} -${file.deletions}`).join('\n') || 'nothing'}\n</diff_stat>`,
441
+ `<verify>\n${verify ? JSON.stringify(verify) : 'the fix was not verified by replaying the finding'}\n</verify>`,
442
+ `<branch>${branch}</branch>`,
443
+ '',
444
+ 'Answer with this JSON object and nothing else:',
445
+ '{ "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" }'
446
+ ].join('\n')
447
+ }];
448
+
449
+ let answer;
450
+ try {
451
+ answer = await callStepWithRetry({
452
+ client,
453
+ jobId,
454
+ runId,
455
+ step: STEPS.fixSummarize,
456
+ system,
457
+ messages: [{ role: 'user', content }],
458
+ maxTokens: SUMMARY_MAX_TOKENS
459
+ });
460
+ } catch (error) {
461
+ if (error instanceof ProxyError && error.isBudget) {
462
+ log('There was no budget left to write the pull request text, so it was written locally instead.');
463
+ return { ...fallback, source: 'local' };
464
+ }
465
+ throw error;
466
+ }
467
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
468
+
469
+ const parsed = parseJsonObject(answer.text) || {};
470
+ const title = typeof parsed.pr_title === 'string' && parsed.pr_title.trim() ? parsed.pr_title.trim() : fallback.prTitle;
471
+ const body = typeof parsed.pr_body === 'string' && parsed.pr_body.trim() ? parsed.pr_body : fallback.prBody;
472
+ return {
473
+ prTitle: clamp(title, 120),
474
+ prBody: clamp(body, 20000),
475
+ source: parsed.pr_title || parsed.pr_body ? 'model' : 'local'
476
+ };
477
+ }
478
+
479
+ /**
480
+ * @param {string} value
481
+ * @param {number} limit
482
+ * @returns {string}
483
+ */
484
+ export function clamp(value, limit) {
485
+ const text = String(value || '');
486
+ return text.length <= limit ? text : text.slice(0, limit);
487
+ }
488
+
489
+ export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan };