backpass 0.1.7 → 0.1.8
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 +39 -15
- package/package.json +1 -1
- package/src/acpx.js +142 -52
- package/src/analyze.js +4 -3
- package/src/apply/terminal.js +4 -3
- package/src/apply/writer.js +206 -19
- package/src/cli.js +3 -3
- package/src/commands/propose.js +60 -6
- package/src/commands/run.js +6 -7
- package/src/harness-invoke.js +277 -0
- package/src/prompts/annotate-preface.md +19 -0
- package/src/prompts/annotate.md +11 -6
- package/src/proposal.js +39 -8
- package/src/skills.js +156 -7
- package/src/state.js +5 -1
- package/src/subprocess.js +53 -5
- package/src/synthesize.js +311 -99
- package/src/tui/index.js +12 -0
- package/src/tui/render.js +16 -0
- package/templates/apply.html +26 -16
package/src/subprocess.js
CHANGED
|
@@ -8,21 +8,35 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
*
|
|
9
9
|
* @param {string} bin
|
|
10
10
|
* @param {string[]} args
|
|
11
|
-
* @param {{ timeoutMs?: number, cwd?: string, input?: string }} [options]
|
|
11
|
+
* @param {{ timeoutMs?: number, cwd?: string, input?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
12
12
|
* @returns {Promise<{ code: number | null, stdout: string, stderr: string, timedOut?: boolean, spawnError?: NodeJS.ErrnoException }>}
|
|
13
13
|
*/
|
|
14
|
-
export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
|
|
14
|
+
export function runCapture(bin, args, { timeoutMs, cwd, input, env } = {}) {
|
|
15
15
|
return new Promise((resolve) => {
|
|
16
|
-
const child = spawn(bin, args, {
|
|
16
|
+
const child = spawn(bin, args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
20
|
+
// Give a timed command its own POSIX process group. acpx launches adapter
|
|
21
|
+
// wrappers and harnesses below itself, and killing acpx alone leaves those
|
|
22
|
+
// descendants running with our capture pipes open.
|
|
23
|
+
detached: process.platform !== "win32" && Boolean(timeoutMs),
|
|
24
|
+
});
|
|
17
25
|
let stdout = "";
|
|
18
26
|
let stderr = "";
|
|
19
27
|
let timedOut = false;
|
|
28
|
+
let escalationTimer = null;
|
|
20
29
|
|
|
21
30
|
const timer = timeoutMs
|
|
22
31
|
? setTimeout(() => {
|
|
23
32
|
timedOut = true;
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
killWindowsTree(child.pid);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
killPosixGroup(child, "SIGTERM");
|
|
38
|
+
escalationTimer = setTimeout(() => killPosixGroup(child, "SIGKILL"), 5000);
|
|
39
|
+
escalationTimer.unref();
|
|
26
40
|
}, timeoutMs)
|
|
27
41
|
: null;
|
|
28
42
|
|
|
@@ -34,10 +48,13 @@ export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
|
|
|
34
48
|
});
|
|
35
49
|
child.on("error", (err) => {
|
|
36
50
|
if (timer) clearTimeout(timer);
|
|
51
|
+
if (escalationTimer) clearTimeout(escalationTimer);
|
|
37
52
|
resolve({ code: null, stdout, stderr: `${stderr}${err.message}`, spawnError: err });
|
|
38
53
|
});
|
|
39
54
|
child.on("close", (code) => {
|
|
40
55
|
if (timer) clearTimeout(timer);
|
|
56
|
+
if (timedOut && process.platform !== "win32") killPosixGroup(child, "SIGKILL");
|
|
57
|
+
if (escalationTimer) clearTimeout(escalationTimer);
|
|
41
58
|
resolve({ code, stdout, stderr, timedOut });
|
|
42
59
|
});
|
|
43
60
|
|
|
@@ -45,3 +62,34 @@ export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
|
|
|
45
62
|
else child.stdin.end();
|
|
46
63
|
});
|
|
47
64
|
}
|
|
65
|
+
|
|
66
|
+
function killPosixGroup(child, signal) {
|
|
67
|
+
if (!child.pid) return;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-child.pid, signal);
|
|
70
|
+
} catch {
|
|
71
|
+
// Preserve direct-child cleanup if a platform cannot address the group.
|
|
72
|
+
try {
|
|
73
|
+
child.kill(signal);
|
|
74
|
+
} catch {
|
|
75
|
+
// The child already closed.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function killWindowsTree(pid) {
|
|
81
|
+
if (!pid) return;
|
|
82
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
});
|
|
86
|
+
killer.on("error", () => {
|
|
87
|
+
// taskkill is part of Windows, but retain direct-child cleanup if it cannot start.
|
|
88
|
+
try {
|
|
89
|
+
process.kill(pid, "SIGKILL");
|
|
90
|
+
} catch {
|
|
91
|
+
// The child already closed.
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
killer.unref();
|
|
95
|
+
}
|
package/src/synthesize.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { extractJson, openSession, usageRecord } from "./acpx.js";
|
|
5
5
|
import { renderEvidenceForPrompt } from "./fold.js";
|
|
6
6
|
import { renderInstructionIndex } from "./memory.js";
|
|
7
|
-
import { renderPrompt } from "./prompts.js";
|
|
7
|
+
import { renderPrompt, render, loadPrompt } from "./prompts.js";
|
|
8
8
|
import { buildProposal, effectiveMaxEdits, ProposalViolation, renderChangesForPrompt } from "./proposal.js";
|
|
9
9
|
import { loadSkills, renderSkillIndex, resolveOverflowTarget } from "./skills.js";
|
|
10
10
|
import { isSuppressedByRejection } from "./state.js";
|
|
@@ -13,26 +13,55 @@ import { measureWorkspace, prepareWorkspace, repoFingerprint } from "./workspace
|
|
|
13
13
|
import { UserError, color, info, warn } from "./logger.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Stage 3 of the pipeline (design section 3):
|
|
17
|
-
*
|
|
16
|
+
* Stage 3 of the pipeline (design section 3): high-reasoning synthesis that turns folded
|
|
17
|
+
* evidence into concrete edits.
|
|
18
18
|
*
|
|
19
19
|
* The agent never describes an edit for backpass to locate - it makes the edit, with its
|
|
20
20
|
* harness's own file tools, in a staging copy of the memory file (`src/workspace.js`).
|
|
21
|
-
*
|
|
21
|
+
* A run starts in one session with two kinds of turn:
|
|
22
22
|
*
|
|
23
23
|
* edit the synthesis prompt; the agent edits `./AGENTS.md` in the staging copy
|
|
24
24
|
* annotate backpass measures the copy against the original (`src/diff.js`) and shows
|
|
25
25
|
* the changes by id; the agent attaches kind, title, rationale, and evidence
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* An empty annotation turn is retried once in a fresh session, as described below.
|
|
28
|
+
*
|
|
29
|
+
* The annotation is what the mechanical gates validate (`buildProposal`). A parseable
|
|
30
|
+
* gate-rejected answer is saved before the agent is re-prompted with the exact breaches;
|
|
31
|
+
* judged answers are bounded by ANNOTATE_TURNS, then backpass fails loudly rather than
|
|
32
|
+
* quietly trimming the result (design section 6). The repo is fingerprinted around each
|
|
33
|
+
* turn; a harness that wrote past the staging copy is an error, never a silent apply.
|
|
34
|
+
*
|
|
35
|
+
* Three things an annotate turn can be are deliberately kept apart, because they call for
|
|
36
|
+
* different responses and produce different advice at the end of a failed run:
|
|
37
|
+
*
|
|
38
|
+
* the files moved the ids the agent was asked about no longer exist. It is shown the
|
|
39
|
+
* fresh measurement and answers again; this is not a failed
|
|
40
|
+
* annotation and never costs an annotation attempt (REMEASURE_TURNS
|
|
41
|
+
* bounds it instead).
|
|
42
|
+
* the turn was empty the adapter returned success with no text at all. The model never
|
|
43
|
+
* spoke, so there is nothing to correct - the annotation is retried
|
|
44
|
+
* once in a NEW session, since the accumulated context of the old
|
|
45
|
+
* one is the likeliest reason it collapsed.
|
|
46
|
+
* the answer was judged the model spoke and the gates ruled. Only this consumes an
|
|
47
|
+
* annotation attempt, and only this writes a rejected proposal.
|
|
32
48
|
*/
|
|
33
49
|
|
|
34
|
-
/** Annotation
|
|
50
|
+
/** Annotation attempts per run: the first answer plus re-prompts with the exact violations. */
|
|
35
51
|
export const ANNOTATE_TURNS = 3;
|
|
52
|
+
/**
|
|
53
|
+
* Turns per run the agent may spend re-editing instead of answering. Counted for the whole
|
|
54
|
+
* run, not consecutively: an agent that alternates editing and answering is still an agent
|
|
55
|
+
* that never finishes, and the loop has to end.
|
|
56
|
+
*/
|
|
57
|
+
export const REMEASURE_TURNS = 3;
|
|
58
|
+
/** Fresh-session retries for an adapter turn that produced no text at all. */
|
|
59
|
+
export const EMPTY_TURN_RETRIES = 1;
|
|
60
|
+
|
|
61
|
+
const EMPTY_TURN_VIOLATION =
|
|
62
|
+
"the synthesis harness ended its turn with no output at all - no JSON, no prose, no tool call";
|
|
63
|
+
const UNPARSEABLE_VIOLATION = "synthesis answered with text, but not with a JSON object";
|
|
64
|
+
const KEPT_EDITING_VIOLATION = "synthesis kept editing the staging copy instead of annotating the measured changes";
|
|
36
65
|
|
|
37
66
|
function budgetRule(memoryFile, config, maxEdits) {
|
|
38
67
|
const remaining = config.budgetTokens - memoryFile.tokens;
|
|
@@ -93,13 +122,16 @@ function assertRepoUntouched(repo, before, workspaceRoot) {
|
|
|
93
122
|
);
|
|
94
123
|
}
|
|
95
124
|
|
|
96
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Everything the edit and annotation turns need: prompt values, the `buildProposal`
|
|
127
|
+
* context, and the overflow target.
|
|
128
|
+
*/
|
|
129
|
+
function synthesisSetup({ memoryFile, summary, config, repo, harnessCounts }) {
|
|
97
130
|
const state = config.state;
|
|
98
131
|
const rejections = state.readRejections();
|
|
99
132
|
const overflow = resolveOverflowTarget(repo.root, config.skillsDir);
|
|
100
133
|
for (const w of overflow.warnings) warn(w);
|
|
101
134
|
const skillFiles = loadSkills(repo.root, overflow.dir);
|
|
102
|
-
const harnessCounts = harnessCountsOf(transcripts);
|
|
103
135
|
const maxEdits = effectiveMaxEdits(memoryFile, config);
|
|
104
136
|
|
|
105
137
|
const common = {
|
|
@@ -108,6 +140,228 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
|
|
|
108
140
|
MAX_EDITS: String(maxEdits),
|
|
109
141
|
MIN_GAP_EVIDENCE: String(config.minGapEvidence),
|
|
110
142
|
};
|
|
143
|
+
|
|
144
|
+
const context = {
|
|
145
|
+
memoryFile,
|
|
146
|
+
config: { ...config, skillsDir: overflow.dir },
|
|
147
|
+
repo,
|
|
148
|
+
summary,
|
|
149
|
+
harnessCounts,
|
|
150
|
+
rejections,
|
|
151
|
+
isSuppressed: isSuppressedByRejection,
|
|
152
|
+
skillFiles,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const promptDir = path.join(state.root, "prompts");
|
|
156
|
+
fs.mkdirSync(promptDir, { recursive: true });
|
|
157
|
+
|
|
158
|
+
return { state, rejections, overflow, skillFiles, maxEdits, common, context, promptDir };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The header a fresh annotation session needs. An in-session annotate turn inherits the
|
|
163
|
+
* repository, the budget, and the evidence from the editing turn that preceded it; the
|
|
164
|
+
* fresh session used after an empty reply would otherwise be asked to quote evidence it
|
|
165
|
+
* has never been shown.
|
|
166
|
+
*/
|
|
167
|
+
function prefaceFor({ memoryFile, summary, config, repo, workspaceRoot }) {
|
|
168
|
+
return render(loadPrompt("annotate-preface"), {
|
|
169
|
+
MEMORY_PATH: memoryFile.path,
|
|
170
|
+
REPO_NAME: repo.name,
|
|
171
|
+
REPO_ROOT: repo.root,
|
|
172
|
+
WORKSPACE_ROOT: workspaceRoot,
|
|
173
|
+
CURRENT_TOKENS: String(memoryFile.tokens),
|
|
174
|
+
BUDGET_STATE: budgetState(memoryFile, config),
|
|
175
|
+
TRANSCRIPT_COUNT: String(summary.analyzedSessions),
|
|
176
|
+
EVIDENCE: renderEvidenceForPrompt(summary),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const REMEASURE_NOTICE =
|
|
181
|
+
`\n\n## The files moved after they were measured\n\nYou changed the files again during your last turn, so the ids you were given no longer ` +
|
|
182
|
+
`describe them. Nothing is wrong with the shape of your answer - annotate the re-measured ` +
|
|
183
|
+
`changes above instead. This did not use up an annotation attempt.\n`;
|
|
184
|
+
|
|
185
|
+
const rejectionBlock = (violations) =>
|
|
186
|
+
`\n\n## Your previous answer was rejected\n\nIt violated these hard rules. Fix every one of them ` +
|
|
187
|
+
`(edit the files first if a change must go or move) and return the corrected JSON object only.\n\n` +
|
|
188
|
+
`${violations.map((v) => `- ${v}`).join("\n")}\n`;
|
|
189
|
+
|
|
190
|
+
/** The headline of a failed run, named after the condition it actually ended on. */
|
|
191
|
+
function terminalMessage(reason, attempts, violations) {
|
|
192
|
+
if (reason === "empty") {
|
|
193
|
+
return "synthesis ended its turn with no output, in the run's session and again in a fresh one";
|
|
194
|
+
}
|
|
195
|
+
if (reason === "editing") {
|
|
196
|
+
return `synthesis kept editing the staging copy instead of annotating it (${REMEASURE_TURNS} re-measurements)`;
|
|
197
|
+
}
|
|
198
|
+
return (
|
|
199
|
+
`synthesis could not produce a valid proposal after ${Math.max(attempts - 1, 0)} re-prompt(s) ` +
|
|
200
|
+
`(${violations.length} violation(s))`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Drive the annotate turns to a valid proposal, or throw a `ProposalViolation` describing
|
|
206
|
+
* the condition the run actually ended on.
|
|
207
|
+
*
|
|
208
|
+
* `holder.session` is the live session; the loop replaces it when it needs a fresh one and
|
|
209
|
+
* the caller closes whatever is in the holder at the end.
|
|
210
|
+
*/
|
|
211
|
+
async function annotateLoop({
|
|
212
|
+
holder,
|
|
213
|
+
freshSession,
|
|
214
|
+
workspace,
|
|
215
|
+
fingerprint,
|
|
216
|
+
repo,
|
|
217
|
+
context,
|
|
218
|
+
common,
|
|
219
|
+
promptDir,
|
|
220
|
+
timeoutSeconds,
|
|
221
|
+
promptRetries,
|
|
222
|
+
usage,
|
|
223
|
+
notes,
|
|
224
|
+
noteOnce,
|
|
225
|
+
overflow,
|
|
226
|
+
progress,
|
|
227
|
+
renderPreface,
|
|
228
|
+
startFresh = false,
|
|
229
|
+
}) {
|
|
230
|
+
const { memoryFile, config } = context;
|
|
231
|
+
const state = config.state;
|
|
232
|
+
|
|
233
|
+
let attempts = 0;
|
|
234
|
+
let remeasures = 0;
|
|
235
|
+
let emptyTurns = 0;
|
|
236
|
+
let violationsToShow = [];
|
|
237
|
+
let justRemeasured = false;
|
|
238
|
+
let owePreface = startFresh;
|
|
239
|
+
/** @type {{ attempt: number, violations: string[] } | null} */
|
|
240
|
+
let saved = null;
|
|
241
|
+
/** @type {{ reason: string, violations: string[] }} */
|
|
242
|
+
let terminal;
|
|
243
|
+
|
|
244
|
+
for (let turn = 1; ; turn += 1) {
|
|
245
|
+
assertRepoUntouched(repo, fingerprint, workspace.root);
|
|
246
|
+
const measured = measureWorkspace(workspace);
|
|
247
|
+
|
|
248
|
+
let prompt = renderPrompt("annotate", {
|
|
249
|
+
...common,
|
|
250
|
+
PREFACE: owePreface ? renderPreface() : "",
|
|
251
|
+
CHANGES: renderChangesForPrompt(measured, memoryFile),
|
|
252
|
+
});
|
|
253
|
+
owePreface = false;
|
|
254
|
+
if (justRemeasured) prompt += REMEASURE_NOTICE;
|
|
255
|
+
else if (violationsToShow.length) prompt += rejectionBlock(violationsToShow);
|
|
256
|
+
|
|
257
|
+
const promptFile = path.join(promptDir, `synthesis-annotate-${turn}.md`);
|
|
258
|
+
fs.writeFileSync(promptFile, prompt);
|
|
259
|
+
progress("annotate", { attempt: attempts + 1, turn, changes: measured.changes.length });
|
|
260
|
+
|
|
261
|
+
const result = await holder.prompt({
|
|
262
|
+
promptFile,
|
|
263
|
+
approveAll: true,
|
|
264
|
+
timeoutSeconds,
|
|
265
|
+
promptRetries,
|
|
266
|
+
});
|
|
267
|
+
usage.push(usageRecord(holder.ranWith, result));
|
|
268
|
+
for (const note of result.notes || []) noteOnce(note);
|
|
269
|
+
|
|
270
|
+
// The agent may keep editing during an annotate turn; the ids it was answering about
|
|
271
|
+
// are then stale, so the answer is dropped and the fresh measurement shown instead.
|
|
272
|
+
// That is a measurement problem, not a failed annotation: it costs no attempt.
|
|
273
|
+
assertRepoUntouched(repo, fingerprint, workspace.root);
|
|
274
|
+
if (measureWorkspace(workspace).signature !== measured.signature) {
|
|
275
|
+
remeasures += 1;
|
|
276
|
+
justRemeasured = true;
|
|
277
|
+
violationsToShow = [];
|
|
278
|
+
if (remeasures >= REMEASURE_TURNS) {
|
|
279
|
+
terminal = { reason: "editing", violations: [KEPT_EDITING_VIOLATION] };
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
warn(
|
|
283
|
+
`synthesis edited the staging copy again; re-measuring and re-annotating ` +
|
|
284
|
+
`(annotation attempt ${attempts + 1} of ${ANNOTATE_TURNS} is still unspent)`,
|
|
285
|
+
);
|
|
286
|
+
emitProgress("synth:remeasure", { turn, attempt: attempts + 1, remeasures });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
justRemeasured = false;
|
|
290
|
+
|
|
291
|
+
// An empty turn is not a bad answer; it is no answer. Retry it once in a new session,
|
|
292
|
+
// because the accumulated context of this one is the likeliest reason it collapsed.
|
|
293
|
+
if (!(result.text || "").trim()) {
|
|
294
|
+
emptyTurns += 1;
|
|
295
|
+
if (emptyTurns > EMPTY_TURN_RETRIES) {
|
|
296
|
+
terminal = { reason: "empty", violations: [EMPTY_TURN_VIOLATION] };
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
warn("synthesis ended its turn with no output; retrying the annotation once in a fresh session");
|
|
300
|
+
emitProgress("synth:empty", { turn, attempt: attempts + 1, emptyTurns });
|
|
301
|
+
await holder.session.close();
|
|
302
|
+
holder.session = await freshSession();
|
|
303
|
+
// The new session knows nothing, so it is given the preface; `violationsToShow` is
|
|
304
|
+
// kept because those gates are still what the run's annotation has to satisfy.
|
|
305
|
+
owePreface = true;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
attempts += 1;
|
|
310
|
+
const parsed = extractJson(result.text);
|
|
311
|
+
if (!parsed) {
|
|
312
|
+
violationsToShow = [UNPARSEABLE_VIOLATION];
|
|
313
|
+
if (attempts >= ANNOTATE_TURNS) {
|
|
314
|
+
terminal = { reason: "unparseable", violations: violationsToShow };
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
warn(`synthesis violated 1 gate(s); re-prompting with the exact violations`);
|
|
318
|
+
emitProgress("synth:violations", { attempt: attempts, violations: violationsToShow });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const { proposal, violations } = buildProposal(parsed, { ...context, measured });
|
|
323
|
+
proposal.notes = [...proposal.notes, ...notes];
|
|
324
|
+
proposal.usage = usage;
|
|
325
|
+
proposal.overflowTarget = overflow;
|
|
326
|
+
proposal.attempt = attempts;
|
|
327
|
+
if (!violations.length) {
|
|
328
|
+
emitProgress("synth:done", { edits: proposal.edits.length, attempt: attempts });
|
|
329
|
+
return { proposal, violations: [] };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
violationsToShow = violations;
|
|
333
|
+
proposal.violations = violations;
|
|
334
|
+
// Keep the rejected proposal so a loud failure is still inspectable. It records which
|
|
335
|
+
// attempt produced it, so a later empty turn cannot be reported as its author.
|
|
336
|
+
state.writeProposal(proposal);
|
|
337
|
+
saved = { attempt: attempts, violations };
|
|
338
|
+
if (attempts >= ANNOTATE_TURNS) {
|
|
339
|
+
terminal = { reason: "gates", violations };
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
warn(`synthesis violated ${violations.length} gate(s); re-prompting with the exact violations`);
|
|
343
|
+
emitProgress("synth:violations", { attempt: attempts, violations });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
throw new ProposalViolation(terminalMessage(terminal.reason, attempts, terminal.violations), terminal.violations, {
|
|
347
|
+
reason: terminal.reason,
|
|
348
|
+
attempts,
|
|
349
|
+
saved,
|
|
350
|
+
proposalPath: saved ? state.proposalPath : null,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function synthesizeProposal({ memoryFile, summary, config, repo, transcripts, runNote = "" }) {
|
|
355
|
+
config.state.clearProposal();
|
|
356
|
+
const harnessCounts = harnessCountsOf(transcripts);
|
|
357
|
+
const { state, rejections, overflow, skillFiles, maxEdits, common, context, promptDir } = synthesisSetup({
|
|
358
|
+
memoryFile,
|
|
359
|
+
summary,
|
|
360
|
+
config,
|
|
361
|
+
repo,
|
|
362
|
+
harnessCounts,
|
|
363
|
+
});
|
|
364
|
+
|
|
111
365
|
const editValues = {
|
|
112
366
|
...common,
|
|
113
367
|
REPO_NAME: repo.name,
|
|
@@ -128,19 +382,6 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
|
|
|
128
382
|
REJECTIONS: renderRejections(rejections),
|
|
129
383
|
};
|
|
130
384
|
|
|
131
|
-
const context = {
|
|
132
|
-
memoryFile,
|
|
133
|
-
config: { ...config, skillsDir: overflow.dir },
|
|
134
|
-
repo,
|
|
135
|
-
summary,
|
|
136
|
-
harnessCounts,
|
|
137
|
-
rejections,
|
|
138
|
-
isSuppressed: isSuppressedByRejection,
|
|
139
|
-
skillFiles,
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const promptDir = path.join(state.root, "prompts");
|
|
143
|
-
fs.mkdirSync(promptDir, { recursive: true });
|
|
144
385
|
const editPromptFile = path.join(promptDir, "synthesis-edit.md");
|
|
145
386
|
fs.writeFileSync(editPromptFile, renderPrompt("synthesis", editValues));
|
|
146
387
|
|
|
@@ -163,6 +404,7 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
|
|
|
163
404
|
`${pick.effort ? ` effort=${pick.effort}` : ""}`,
|
|
164
405
|
);
|
|
165
406
|
let ranWith = pick.agent;
|
|
407
|
+
let chosen = pick;
|
|
166
408
|
const progress = (phase, extra = {}) =>
|
|
167
409
|
emitProgress("synth:start", {
|
|
168
410
|
agent: ranWith,
|
|
@@ -181,16 +423,25 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
|
|
|
181
423
|
// through to the next ladder candidate; the switch is recorded in the notes so the
|
|
182
424
|
// proposal's provenance is visible. Once the editing turn has run, later turns stay
|
|
183
425
|
// on the same candidate - a run never silently switches models after real work.
|
|
184
|
-
/** @type {Awaited<ReturnType<typeof openSession>> | null} */
|
|
185
|
-
|
|
426
|
+
/** @type {{ session: Awaited<ReturnType<typeof openSession>> | null, ranWith: string, prompt: Function }} */
|
|
427
|
+
const holder = {
|
|
428
|
+
session: null,
|
|
429
|
+
ranWith,
|
|
430
|
+
prompt(args) {
|
|
431
|
+
if (!this.session) throw new Error("synthesis session is not open");
|
|
432
|
+
return this.session.prompt(args);
|
|
433
|
+
},
|
|
434
|
+
};
|
|
186
435
|
/** @type {ReturnType<typeof prepareWorkspace>} */
|
|
187
436
|
let workspace = null;
|
|
188
437
|
const editResult = await config.agents.withFallthrough("synthesis", async (current) => {
|
|
189
438
|
ranWith = current.agent;
|
|
439
|
+
holder.ranWith = current.agent;
|
|
440
|
+
chosen = current;
|
|
190
441
|
if (current !== pick) notes.push(`synthesis fell through to ${current.agent} (${current.model})`);
|
|
191
442
|
workspace = prepareWorkspace({ state, repo, memoryFile, skillsDir: overflow.dir });
|
|
192
443
|
progress("edit", { attempt: 1 });
|
|
193
|
-
session = await openSession({
|
|
444
|
+
holder.session = await openSession({
|
|
194
445
|
agent: current.agent,
|
|
195
446
|
model: current.model,
|
|
196
447
|
effort: current.effort,
|
|
@@ -198,90 +449,51 @@ export async function synthesizeProposal({ memoryFile, summary, config, repo, tr
|
|
|
198
449
|
cwd: workspace.root,
|
|
199
450
|
});
|
|
200
451
|
try {
|
|
201
|
-
return await session.prompt({
|
|
452
|
+
return await holder.session.prompt({
|
|
202
453
|
promptFile: editPromptFile,
|
|
203
454
|
approveAll: true,
|
|
204
455
|
timeoutSeconds,
|
|
205
456
|
promptRetries: config.promptRetries,
|
|
206
457
|
});
|
|
207
458
|
} catch (err) {
|
|
208
|
-
await session.close();
|
|
209
|
-
session = null;
|
|
459
|
+
await holder.session.close();
|
|
460
|
+
holder.session = null;
|
|
210
461
|
throw err;
|
|
211
462
|
}
|
|
212
463
|
});
|
|
213
464
|
usage.push(usageRecord(ranWith, editResult));
|
|
214
465
|
for (const note of editResult.notes || []) noteOnce(note);
|
|
215
466
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
CHANGES: renderChangesForPrompt(measured, memoryFile),
|
|
226
|
-
});
|
|
227
|
-
if (lastViolations.length) {
|
|
228
|
-
prompt +=
|
|
229
|
-
`\n\n## Your previous answer was rejected\n\nIt violated these hard rules. Fix every one of them ` +
|
|
230
|
-
`(edit the files first if a change must go or move) and return the corrected JSON object only.\n\n` +
|
|
231
|
-
`${lastViolations.map((v) => `- ${v}`).join("\n")}\n`;
|
|
232
|
-
}
|
|
233
|
-
const promptFile = path.join(promptDir, `synthesis-annotate-${attempt}.md`);
|
|
234
|
-
fs.writeFileSync(promptFile, prompt);
|
|
235
|
-
progress("annotate", { attempt, changes: measured.changes.length });
|
|
236
|
-
|
|
237
|
-
const result = await turn.prompt({
|
|
238
|
-
promptFile,
|
|
239
|
-
approveAll: true,
|
|
240
|
-
timeoutSeconds,
|
|
241
|
-
promptRetries: config.promptRetries,
|
|
242
|
-
});
|
|
243
|
-
usage.push(usageRecord(ranWith, result));
|
|
244
|
-
for (const note of result.notes || []) noteOnce(note);
|
|
245
|
-
|
|
246
|
-
// The agent may keep editing during an annotate turn; ids are then stale, so the
|
|
247
|
-
// answer is discarded and the fresh measurement is shown instead.
|
|
248
|
-
assertRepoUntouched(repo, fingerprint, workspace.root);
|
|
249
|
-
const remeasured = measureWorkspace(workspace);
|
|
250
|
-
if (remeasured.signature !== measured.signature) {
|
|
251
|
-
lastViolations = [
|
|
252
|
-
"the files changed after the changes were measured; annotate the re-measured changes shown above",
|
|
253
|
-
];
|
|
254
|
-
} else {
|
|
255
|
-
const parsed = extractJson(result.text);
|
|
256
|
-
if (!parsed) {
|
|
257
|
-
lastViolations = ["synthesis returned no parseable JSON object"];
|
|
258
|
-
} else {
|
|
259
|
-
const { proposal, violations } = buildProposal(parsed, { ...context, measured });
|
|
260
|
-
proposal.notes = [...proposal.notes, ...notes];
|
|
261
|
-
proposal.usage = usage;
|
|
262
|
-
proposal.overflowTarget = overflow;
|
|
263
|
-
if (!violations.length) {
|
|
264
|
-
emitProgress("synth:done", { edits: proposal.edits.length, attempt });
|
|
265
|
-
return { proposal, violations: [] };
|
|
266
|
-
}
|
|
267
|
-
lastViolations = violations;
|
|
268
|
-
proposal.violations = violations;
|
|
269
|
-
// Keep the rejected proposal so a loud failure is still inspectable.
|
|
270
|
-
state.writeProposal(proposal);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (attempt < ANNOTATE_TURNS) {
|
|
275
|
-
warn(`synthesis violated ${lastViolations.length} gate(s); re-prompting with the exact violations`);
|
|
276
|
-
emitProgress("synth:violations", { attempt, violations: lastViolations });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
467
|
+
let serial = 1;
|
|
468
|
+
const freshSession = () =>
|
|
469
|
+
openSession({
|
|
470
|
+
agent: chosen.agent,
|
|
471
|
+
model: chosen.model,
|
|
472
|
+
effort: chosen.effort,
|
|
473
|
+
sessionName: `${sessionName}-r${(serial += 1)}`,
|
|
474
|
+
cwd: workspace.root,
|
|
475
|
+
});
|
|
279
476
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
477
|
+
try {
|
|
478
|
+
return await annotateLoop({
|
|
479
|
+
holder,
|
|
480
|
+
freshSession,
|
|
481
|
+
workspace,
|
|
482
|
+
fingerprint,
|
|
483
|
+
repo,
|
|
484
|
+
context,
|
|
485
|
+
common,
|
|
486
|
+
promptDir,
|
|
487
|
+
timeoutSeconds,
|
|
488
|
+
promptRetries: config.promptRetries,
|
|
489
|
+
usage,
|
|
490
|
+
notes,
|
|
491
|
+
noteOnce,
|
|
492
|
+
overflow,
|
|
493
|
+
progress,
|
|
494
|
+
renderPreface: () => prefaceFor({ memoryFile, summary, config, repo, workspaceRoot: workspace.root }),
|
|
495
|
+
});
|
|
284
496
|
} finally {
|
|
285
|
-
await
|
|
497
|
+
await holder.session.close();
|
|
286
498
|
}
|
|
287
499
|
}
|
package/src/tui/index.js
CHANGED
|
@@ -103,6 +103,7 @@ export function initialState(meta) {
|
|
|
103
103
|
instructions: 0,
|
|
104
104
|
suppressed: 0,
|
|
105
105
|
violations: [],
|
|
106
|
+
annotateCondition: null,
|
|
106
107
|
edits: 0,
|
|
107
108
|
},
|
|
108
109
|
};
|
|
@@ -233,6 +234,17 @@ export function reduceEvent(state, event, data, now = Date.now()) {
|
|
|
233
234
|
break;
|
|
234
235
|
case "synth:violations":
|
|
235
236
|
s.violations = data.violations || [];
|
|
237
|
+
s.annotateCondition = null;
|
|
238
|
+
break;
|
|
239
|
+
// Neither of these is a rejected answer, so neither clears into the violations list:
|
|
240
|
+
// the files moved under the ids, or the harness said nothing at all.
|
|
241
|
+
case "synth:remeasure":
|
|
242
|
+
s.annotateCondition = "remeasure";
|
|
243
|
+
s.violations = [];
|
|
244
|
+
break;
|
|
245
|
+
case "synth:empty":
|
|
246
|
+
s.annotateCondition = "empty";
|
|
247
|
+
s.violations = [];
|
|
236
248
|
break;
|
|
237
249
|
case "synth:done":
|
|
238
250
|
s.status = "done";
|
package/src/tui/render.js
CHANGED
|
@@ -247,6 +247,9 @@ function synthSummary(theme, s) {
|
|
|
247
247
|
const model = [s.agent, s.model].filter(Boolean).join(" · ");
|
|
248
248
|
const effort = s.effort ? ` · effort ${s.effort}` : "";
|
|
249
249
|
if (s.status === "done") return theme.paint(`${s.edits} edit(s) · passed validation`, "dim");
|
|
250
|
+
if (s.phase === "annotate" && s.annotateCondition === "empty") {
|
|
251
|
+
return theme.paint("fresh session after an empty turn · ", "dim") + theme.paint(`${model}${effort}`, "dim");
|
|
252
|
+
}
|
|
250
253
|
if (s.phase === "annotate" && s.attempt > 1) {
|
|
251
254
|
return (
|
|
252
255
|
theme.paint("re-prompt ", "dim") +
|
|
@@ -384,6 +387,19 @@ function synthesizeDetail(state, theme, width, spin) {
|
|
|
384
387
|
sectionRule(theme, STAGE_LABELS.synthesize, `aggregated gradients → at most ${state.meta.maxEdits} edits`, width),
|
|
385
388
|
];
|
|
386
389
|
|
|
390
|
+
// A re-measurement and an empty turn are named for what they are: neither spent one of
|
|
391
|
+
// the annotation attempts, and saying "violated 1 gate(s)" for either is how a run gets
|
|
392
|
+
// read as almost-passing when nothing was judged at all.
|
|
393
|
+
if (s.phase === "annotate" && s.annotateCondition === "remeasure") {
|
|
394
|
+
lines.push(
|
|
395
|
+
` ${theme.paint("~", "yellow")} ${theme.paint("the files moved; re-annotating the new ids", "text")} ${theme.paint("· no annotation attempt spent", "dim")}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (s.phase === "annotate" && s.annotateCondition === "empty") {
|
|
399
|
+
lines.push(
|
|
400
|
+
` ${theme.paint("~", "yellow")} ${theme.paint("the harness returned an empty turn", "text")} ${theme.paint("· retrying in a fresh session", "dim")}`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
387
403
|
if (s.phase === "annotate" && s.attempt > 1 && s.violations.length) {
|
|
388
404
|
lines.push(
|
|
389
405
|
` ${theme.paint("!", "yellow")} ${theme.paint(`synthesis violated ${s.violations.length} gate(s)`, "text")} ${theme.paint("· re-prompting with the exact breaches", "dim")}`,
|