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.
- package/README.md +87 -3
- package/package.json +12 -1
- package/src/api/client.js +25 -2
- package/src/cli.js +40 -1
- package/src/commands/fakeCore.js +68 -3
- package/src/commands/fix.js +1053 -0
- package/src/commands/run.js +12 -3
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +430 -0
- package/src/run/git.js +331 -0
- package/src/run/index.js +7 -3
- package/src/run/model.js +7 -1
- package/src/run/repoTools.js +279 -10
package/src/commands/run.js
CHANGED
|
@@ -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/packs/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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 };
|