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.
- package/README.md +90 -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 +1059 -0
- package/src/commands/run.js +12 -3
- package/src/dashboard.js +15 -5
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +489 -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 +373 -12
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/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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
76
|
+
return playtestPage(gameId, '/what-leaves-your-machine', apiUrl);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/**
|
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,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 };
|