copperhead 0.3.0 → 0.5.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/NOTICE +5 -0
- package/README.md +72 -9
- package/dist/agent/ledger.js +7 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +303 -34
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +3 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/anthropic.js +28 -13
- package/dist/agent/providers/anthropic.js.map +1 -1
- package/dist/agent/providers/codex.js +292 -0
- package/dist/agent/providers/codex.js.map +1 -0
- package/dist/agent/render.js +170 -0
- package/dist/agent/render.js.map +1 -0
- package/dist/agent/runmeta.js +124 -0
- package/dist/agent/runmeta.js.map +1 -0
- package/dist/agent/tools.js +117 -16
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +23 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +47 -11
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +9 -2
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +57 -3
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +16 -8
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +58 -8
- package/dist/kicad/cli.js.map +1 -1
- package/dist/memory/constraints.js +63 -3
- package/dist/memory/constraints.js.map +1 -1
- package/dist/memory/drift.js +31 -0
- package/dist/memory/drift.js.map +1 -1
- package/dist/memory/scaffold.js +2 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/memory/synap.js +152 -0
- package/dist/memory/synap.js.map +1 -0
- package/dist/util/git.js +125 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/preflight.js +24 -0
- package/dist/util/preflight.js.map +1 -0
- package/package.json +21 -6
- package/src/agent/ledger.ts +9 -1
- package/src/agent/loop.ts +333 -35
- package/src/agent/prompts.ts +3 -1
- package/src/agent/providers/anthropic.ts +40 -16
- package/src/agent/providers/codex.ts +339 -0
- package/src/agent/render.ts +194 -0
- package/src/agent/runmeta.ts +198 -0
- package/src/agent/tools.ts +119 -15
- package/src/agent/transcript.ts +49 -0
- package/src/agent/types.ts +1 -0
- package/src/cli.ts +51 -12
- package/src/commands/check.ts +9 -3
- package/src/commands/create.ts +61 -4
- package/src/commands/sync.ts +5 -0
- package/src/config.ts +29 -9
- package/src/kicad/cli.ts +60 -9
- package/src/memory/constraints.ts +90 -3
- package/src/memory/drift.ts +32 -0
- package/src/memory/scaffold.ts +2 -1
- package/src/memory/synap.ts +217 -0
- package/src/util/git.ts +134 -4
- package/src/util/preflight.ts +22 -0
package/src/agent/loop.ts
CHANGED
|
@@ -4,16 +4,31 @@ import { execa } from 'execa';
|
|
|
4
4
|
import type { Msg, Provider, Turn } from './types.js';
|
|
5
5
|
import { availableTools, dispatchTool, type RunContext } from './tools.js';
|
|
6
6
|
import { buildSystemPrompt } from './prompts.js';
|
|
7
|
-
import { loadConstraints } from '../memory/constraints.js';
|
|
7
|
+
import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
|
|
8
8
|
import { loadConfig, type CopperheadConfig } from '../config.js';
|
|
9
|
-
import { Transcript } from './transcript.js';
|
|
9
|
+
import { Transcript, type ExitPath, type RunStats } from './transcript.js';
|
|
10
|
+
import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
|
|
11
|
+
import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
|
|
10
12
|
import { ObligationsLedger } from './ledger.js';
|
|
11
|
-
import {
|
|
13
|
+
import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
|
|
12
14
|
import { withRetry, isRateLimit } from '../util/retry.js';
|
|
13
15
|
import { openspecArchive } from '../openspec/cli.js';
|
|
14
16
|
import { existsSync } from 'node:fs';
|
|
15
17
|
import { OpenAIProvider } from './providers/openai.js';
|
|
16
18
|
import { AnthropicProvider } from './providers/anthropic.js';
|
|
19
|
+
import { CodexProvider } from './providers/codex.js';
|
|
20
|
+
import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
|
|
21
|
+
|
|
22
|
+
/** What the user sees at the moment they decide whether to keep going. */
|
|
23
|
+
export interface BudgetExhaustedStats {
|
|
24
|
+
/** The run's original turn budget, before any extensions. */
|
|
25
|
+
maxTurns: number;
|
|
26
|
+
turnsUsed: number;
|
|
27
|
+
tokensIn: number;
|
|
28
|
+
tokensOut: number;
|
|
29
|
+
filesTouched: string[];
|
|
30
|
+
openObligations: number;
|
|
31
|
+
}
|
|
17
32
|
|
|
18
33
|
export interface RunOptions {
|
|
19
34
|
repoRoot: string;
|
|
@@ -24,20 +39,49 @@ export interface RunOptions {
|
|
|
24
39
|
dryRun?: boolean;
|
|
25
40
|
interactive?: boolean;
|
|
26
41
|
confirm?: (q: string) => Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Called when the turn budget runs out. Returns the number of extra turns to
|
|
44
|
+
* grant (0 fails the run as before). Absent means non-interactive: fail.
|
|
45
|
+
*/
|
|
46
|
+
onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
|
|
27
47
|
/** Extra prompt appended for pipeline stages (Mode A). */
|
|
28
48
|
stagePrompt?: string;
|
|
49
|
+
/** Test seam: bypass makeProvider. */
|
|
50
|
+
provider?: Provider;
|
|
29
51
|
log?: (line: string) => void;
|
|
52
|
+
/** Progress renderer; defaults to a plain line renderer over `log`. */
|
|
53
|
+
renderer?: ProgressRenderer;
|
|
54
|
+
/** Caller-known run identity for the metadata block (design D2). */
|
|
55
|
+
meta?: RunMetaInput;
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
export interface RunResult {
|
|
33
59
|
outcome: 'success' | 'refused' | 'failure';
|
|
60
|
+
exitPath: ExitPath;
|
|
34
61
|
summary: string;
|
|
35
62
|
transcriptDir: string;
|
|
36
63
|
filesTouched: string[];
|
|
37
64
|
commit: string | null;
|
|
38
65
|
}
|
|
39
66
|
|
|
40
|
-
export function makeProvider(model: string): Provider {
|
|
67
|
+
export async function makeProvider(model: string): Promise<Provider> {
|
|
68
|
+
if (model === 'codex' || model.startsWith('codex:')) {
|
|
69
|
+
const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
|
|
70
|
+
if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
|
|
71
|
+
const { Codex } = await import('@openai/codex-sdk').catch((err: unknown) => {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Codex provider requires the optional @openai/codex-sdk package; install it alongside Copperhead before using --model codex',
|
|
74
|
+
{ cause: err },
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
return new CodexProvider({
|
|
78
|
+
...(codexModel ? { model: codexModel } : {}),
|
|
79
|
+
client: new Codex({
|
|
80
|
+
// Use the user's installed CLI and its saved login rather than a model API key.
|
|
81
|
+
codexPathOverride: process.env.COPPERHEAD_CODEX_PATH || 'codex',
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
41
85
|
if (model === 'claude' || model.startsWith('claude')) {
|
|
42
86
|
return new AnthropicProvider(model === 'claude' ? undefined : model);
|
|
43
87
|
}
|
|
@@ -84,18 +128,40 @@ async function appendChangelog(
|
|
|
84
128
|
await writeFile(p, lines.join('\n'), 'utf8');
|
|
85
129
|
}
|
|
86
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Owns the Synap session for one run. The bridge is a subprocess, so the
|
|
133
|
+
* shutdown in `finally` is what lets the CLI exit; without it the process
|
|
134
|
+
* hangs after a successful run.
|
|
135
|
+
*/
|
|
87
136
|
export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
88
|
-
const
|
|
137
|
+
const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
|
|
138
|
+
const providers = new Set<Provider>();
|
|
139
|
+
try {
|
|
140
|
+
return await runWithMemory(opts, memory, providers);
|
|
141
|
+
} finally {
|
|
142
|
+
for (const provider of providers) {
|
|
143
|
+
try {
|
|
144
|
+
await provider.close?.();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await memory?.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runWithMemory(
|
|
154
|
+
opts: RunOptions,
|
|
155
|
+
memory: SynapMemory | null,
|
|
156
|
+
providers: Set<Provider>,
|
|
157
|
+
): Promise<RunResult> {
|
|
158
|
+
const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
|
|
159
|
+
const log = (l: string): void => r.log(l);
|
|
89
160
|
const repoRoot = opts.repoRoot;
|
|
90
161
|
const config = await loadConfig(repoRoot);
|
|
91
162
|
const maxTurns = opts.maxTurns ?? config.maxTurns;
|
|
92
163
|
|
|
93
|
-
|
|
94
|
-
throw new Error('not a git repository; copperhead requires git for snapshots and rollback');
|
|
95
|
-
}
|
|
96
|
-
if ((await isDirty(repoRoot)) && !opts.allowDirty) {
|
|
97
|
-
throw new Error('working tree is dirty; commit your changes or pass --allow-dirty (snapshots via git stash create)');
|
|
98
|
-
}
|
|
164
|
+
await gitPreflight(repoRoot, { allowDirty: opts.allowDirty ?? false });
|
|
99
165
|
const snap = await snapshot(repoRoot);
|
|
100
166
|
|
|
101
167
|
const transcript = new Transcript(repoRoot);
|
|
@@ -119,23 +185,127 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
119
185
|
finishRequest: null,
|
|
120
186
|
};
|
|
121
187
|
|
|
122
|
-
let provider = makeProvider(opts.model);
|
|
188
|
+
let provider = opts.provider ?? (await makeProvider(opts.model));
|
|
189
|
+
providers.add(provider);
|
|
190
|
+
|
|
191
|
+
// Deterministic, LLM-free metadata block: collected once, rendered onto all
|
|
192
|
+
// three surfaces (run-start event, summary ## Environment, CLI header) so
|
|
193
|
+
// they can never disagree (design D1, AC-8.1/8.4).
|
|
194
|
+
const startMs = Date.now();
|
|
195
|
+
const meta: RunMeta = await collectRunMeta({
|
|
196
|
+
repoRoot,
|
|
197
|
+
config,
|
|
198
|
+
maxTurns,
|
|
199
|
+
runId: path.basename(transcript.dir),
|
|
200
|
+
request: opts.request,
|
|
201
|
+
model: opts.model,
|
|
202
|
+
provider: provider.name,
|
|
203
|
+
interactive: opts.interactive ?? false,
|
|
204
|
+
input: opts.meta,
|
|
205
|
+
});
|
|
206
|
+
for (const line of renderCliHeader(meta)) log(line);
|
|
207
|
+
// Revisit obligations deferred while their artifact didn't exist re-open now
|
|
208
|
+
// if it does (must run before loadConstraints so the prompt sees the updated
|
|
209
|
+
// registry). They land in this run's fresh ledger, so finish gates on them.
|
|
210
|
+
const reopened = await reopenDeferredAffects(repoRoot, config, (key, item) =>
|
|
211
|
+
ctx.ledger.add('affects-revisit', `${key} affects ${item}`, key),
|
|
212
|
+
);
|
|
213
|
+
if (reopened.length) {
|
|
214
|
+
await transcript.event('deferred-affects-reopened', { reopened });
|
|
215
|
+
log(`re-opened ${reopened.length} deferred constraint revisit obligation(s)`);
|
|
216
|
+
}
|
|
123
217
|
const constraints = await loadConstraints(repoRoot);
|
|
124
|
-
|
|
218
|
+
let basePrompt = await buildSystemPrompt(repoRoot, config, constraints);
|
|
219
|
+
if (reopened.length) {
|
|
220
|
+
basePrompt += [
|
|
221
|
+
'',
|
|
222
|
+
'',
|
|
223
|
+
'## Reopened constraint revisits',
|
|
224
|
+
'',
|
|
225
|
+
'These constraints were recorded before their target artifact existed; the artifact now exists.',
|
|
226
|
+
'Revisit each against the design and close it with resolve_affected (batch the calls):',
|
|
227
|
+
...reopened.map((r) => `- ${r.key} affects ${r.item}`),
|
|
228
|
+
].join('\n');
|
|
229
|
+
}
|
|
230
|
+
// Cross-run memory is appended after the repo's own docs and constraints so
|
|
231
|
+
// that the in-repo sources of truth are what the model reads first.
|
|
232
|
+
const recalled = memory ? await memory.recall(opts.request) : null;
|
|
233
|
+
if (recalled) {
|
|
234
|
+
await transcript.event('synap-recall', { chars: recalled.length });
|
|
235
|
+
log('recalled prior context from Synap memory');
|
|
236
|
+
}
|
|
237
|
+
const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
|
|
125
238
|
const messages: Msg[] = [
|
|
126
239
|
{ role: 'system', content: system },
|
|
127
240
|
{ role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
|
|
128
241
|
];
|
|
129
|
-
await transcript.event('run-start',
|
|
242
|
+
await transcript.event('run-start', meta);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A memory write that fails is reported rather than swallowed, but it does
|
|
246
|
+
* not change the run's outcome: discarding a verified commit because a
|
|
247
|
+
* third-party write failed would be the worse trade.
|
|
248
|
+
*/
|
|
249
|
+
const remember = async (run: RunRecord): Promise<void> => {
|
|
250
|
+
if (!memory) return;
|
|
251
|
+
try {
|
|
252
|
+
await memory.record(run);
|
|
253
|
+
await transcript.event('synap-record', { outcome: run.outcome });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const message = (err as Error).message;
|
|
256
|
+
log(`warning: Synap memory write failed (${message}); this run was not recorded`);
|
|
257
|
+
await transcript.event('synap-record-failed', { error: message });
|
|
258
|
+
}
|
|
259
|
+
};
|
|
130
260
|
|
|
131
261
|
let tokensIn = 0;
|
|
132
262
|
let tokensOut = 0;
|
|
263
|
+
let turnsUsed = 0;
|
|
264
|
+
const perTurn: { turn: number; in: number; out: number }[] = [];
|
|
133
265
|
let plan: string | null = null;
|
|
134
266
|
let nudges = 0;
|
|
135
267
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
268
|
+
const stats = (exitPath: ExitPath): RunStats => ({
|
|
269
|
+
exitPath,
|
|
270
|
+
turnsUsed,
|
|
271
|
+
maxTurns,
|
|
272
|
+
repairCyclesUsed: ctx.repairCycles,
|
|
273
|
+
maxRepairCycles: config.maxRepairCycles,
|
|
274
|
+
tokensIn,
|
|
275
|
+
tokensOut,
|
|
276
|
+
perTurn,
|
|
277
|
+
durationMs: Date.now() - startMs,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** One outcome line, printed last at every terminal branch (AC-8.5). */
|
|
281
|
+
const outcomeLine = (s: RunStats, extra?: string | null): string =>
|
|
282
|
+
[
|
|
283
|
+
s.exitPath,
|
|
284
|
+
ctx.lastErc ? `ERC ${ctx.lastErc.ok ? 'clean' : 'failing'}` : 'ERC not run',
|
|
285
|
+
...(ctx.lastDrc ? [`DRC ${ctx.lastDrc.ok ? 'clean' : 'failing'}`] : []),
|
|
286
|
+
...(extra ? [extra] : []),
|
|
287
|
+
fmtDuration(s.durationMs),
|
|
288
|
+
`${fmtTokens(s.tokensIn)} in / ${fmtTokens(s.tokensOut)} out`,
|
|
289
|
+
].join(' · ');
|
|
290
|
+
|
|
291
|
+
const fail = async (reason: string, exitPath: ExitPath): Promise<RunResult> => {
|
|
292
|
+
await transcript.event('run-failed', { reason, exitPath });
|
|
293
|
+
// Preserve the touched work as a stash entry before the rollback destroys
|
|
294
|
+
// it, so a budget-exhaustion (or any) failure is recoverable (issue #15).
|
|
295
|
+
const preserved = await preserveFailedRun(repoRoot, ctx.runId);
|
|
296
|
+
if (preserved) await transcript.event('work-preserved', { stash: preserved });
|
|
297
|
+
// The rollback itself can fail (git in a bad state). That must not become
|
|
298
|
+
// an unhandled throw that skips run-end and summary.md — the summary is
|
|
299
|
+
// most valuable exactly when the tree is left in an unknown state.
|
|
300
|
+
let restoreError: string | null = null;
|
|
301
|
+
try {
|
|
302
|
+
await restore(repoRoot, snap);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
restoreError = (err as Error).message;
|
|
305
|
+
await transcript.event('restore-failed', { error: restoreError });
|
|
306
|
+
}
|
|
307
|
+
const runStats = stats(exitPath);
|
|
308
|
+
await transcript.event('run-end', runStats);
|
|
139
309
|
const summaryPath = await transcript.writeSummary({
|
|
140
310
|
request: opts.request,
|
|
141
311
|
changeId: ctx.changeId,
|
|
@@ -148,14 +318,27 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
148
318
|
tokensOut,
|
|
149
319
|
outcome: 'failure',
|
|
150
320
|
openObligations: ctx.ledger.isClear ? null : ctx.ledger.describe(),
|
|
151
|
-
detail: reason,
|
|
321
|
+
detail: restoreError ? `${reason}\n\nROLLBACK FAILED: ${restoreError} — the working tree may be in a partial state; inspect it with git status/git diff before rerunning` : reason,
|
|
322
|
+
env: meta,
|
|
323
|
+
stats: runStats,
|
|
152
324
|
});
|
|
153
325
|
log(`run failed: ${reason}`);
|
|
154
|
-
|
|
326
|
+
if (restoreError) {
|
|
327
|
+
log(`WARNING: rollback failed (${restoreError}); the working tree may be in a partial state`);
|
|
328
|
+
} else {
|
|
329
|
+
log(`working tree restored to pre-run snapshot`);
|
|
330
|
+
}
|
|
331
|
+
if (preserved) {
|
|
332
|
+
log(
|
|
333
|
+
`failed work preserved: git stash entry "copperhead failed run ${ctx.runId}" (${preserved.slice(0, 10)}); recover with \`git stash apply\`, discard with \`git stash drop\``,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
155
336
|
log(`transcript: ${transcript.jsonlPath}`);
|
|
156
337
|
log(`summary: ${summaryPath}`);
|
|
338
|
+
r.finish(outcomeLine(runStats));
|
|
157
339
|
return {
|
|
158
340
|
outcome: 'failure',
|
|
341
|
+
exitPath,
|
|
159
342
|
summary: reason,
|
|
160
343
|
transcriptDir: transcript.dir,
|
|
161
344
|
filesTouched: [],
|
|
@@ -163,8 +346,38 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
163
346
|
};
|
|
164
347
|
};
|
|
165
348
|
|
|
166
|
-
|
|
349
|
+
let budget = maxTurns;
|
|
350
|
+
for (let turn = 0; ; turn++) {
|
|
351
|
+
if (turn >= budget) {
|
|
352
|
+
// Budget exhausted. In an attended run this is a user decision made with
|
|
353
|
+
// the cost visible, not an unconditional rollback (issue #15).
|
|
354
|
+
const exhaustStats: BudgetExhaustedStats = {
|
|
355
|
+
maxTurns,
|
|
356
|
+
turnsUsed: turn,
|
|
357
|
+
tokensIn,
|
|
358
|
+
tokensOut,
|
|
359
|
+
filesTouched: [...ctx.filesTouched],
|
|
360
|
+
openObligations: ctx.ledger.openObligations.length,
|
|
361
|
+
};
|
|
362
|
+
let extra = 0;
|
|
363
|
+
if (opts.onBudgetExhausted) {
|
|
364
|
+
try {
|
|
365
|
+
extra = Math.floor(await opts.onBudgetExhausted(exhaustStats));
|
|
366
|
+
} catch {
|
|
367
|
+
// A broken prompt (stdin closed mid-question, dying terminal) must
|
|
368
|
+
// read as "declined" and take the preserve-and-restore path below,
|
|
369
|
+
// not propagate past it and skip the rollback entirely.
|
|
370
|
+
extra = 0;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!Number.isFinite(extra) || extra <= 0) break;
|
|
374
|
+
budget += extra;
|
|
375
|
+
await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
|
|
376
|
+
log(`turn budget extended by ${extra} (now ${budget})`);
|
|
377
|
+
}
|
|
167
378
|
const tools = availableTools(ctx).map((t) => t.schema);
|
|
379
|
+
r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
|
|
380
|
+
r.status('thinking');
|
|
168
381
|
let res: Turn;
|
|
169
382
|
try {
|
|
170
383
|
res = await withRetry(() => provider.chat(messages, tools), {
|
|
@@ -177,14 +390,19 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
177
390
|
log(`failing over ${provider.name} → ${fallback.name}`);
|
|
178
391
|
await transcript.event('provider-failover', { from: provider.name, to: fallback.name });
|
|
179
392
|
provider = fallback;
|
|
393
|
+
providers.add(provider);
|
|
180
394
|
turn--;
|
|
181
395
|
continue;
|
|
182
396
|
}
|
|
183
397
|
}
|
|
184
|
-
return fail(`provider error: ${(err as Error).message}
|
|
398
|
+
return fail(`provider error: ${(err as Error).message}`, 'provider-error');
|
|
399
|
+
} finally {
|
|
400
|
+
r.status(null);
|
|
185
401
|
}
|
|
402
|
+
turnsUsed = turn + 1;
|
|
186
403
|
tokensIn += res.usage.inputTokens;
|
|
187
404
|
tokensOut += res.usage.outputTokens;
|
|
405
|
+
perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
|
|
188
406
|
await transcript.event('assistant', { text: res.text, toolCalls: res.toolCalls });
|
|
189
407
|
|
|
190
408
|
if (res.text) {
|
|
@@ -194,31 +412,36 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
194
412
|
messages.push({ role: 'assistant', content: res.text, toolCalls: res.toolCalls });
|
|
195
413
|
|
|
196
414
|
if (!res.toolCalls.length) {
|
|
197
|
-
|
|
415
|
+
// Only *consecutive* tool-less turns are a stall. Providers emit the
|
|
416
|
+
// occasional empty completion mid-run (observed live: three empties
|
|
417
|
+
// spread across 31 productive turns); a cumulative counter turns those
|
|
418
|
+
// into a full rollback of an otherwise-converging run.
|
|
419
|
+
if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
|
|
198
420
|
messages.push({
|
|
199
421
|
role: 'user',
|
|
200
422
|
content: 'Continue using tools, or call finish({outcome, summary}) to end the run.',
|
|
201
423
|
});
|
|
202
424
|
continue;
|
|
203
425
|
}
|
|
426
|
+
nudges = 0;
|
|
204
427
|
|
|
205
428
|
for (const call of res.toolCalls) {
|
|
206
429
|
const result = await dispatchTool(ctx, call.name, call.args);
|
|
207
430
|
await transcript.event('tool', { name: call.name, args: call.args, result });
|
|
208
|
-
|
|
431
|
+
r.toolResult(call.name, result.split('\n')[0] ?? '');
|
|
209
432
|
messages.push({ role: 'tool', toolCallId: call.id, content: result });
|
|
210
433
|
}
|
|
211
434
|
|
|
212
435
|
if (ctx.repairCycles > config.maxRepairCycles) {
|
|
213
|
-
return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist
|
|
436
|
+
return fail(`repair cycles exhausted (${config.maxRepairCycles}); violations persist`, 'repair-cycles-exhausted');
|
|
214
437
|
}
|
|
215
438
|
|
|
216
|
-
const remaining =
|
|
439
|
+
const remaining = budget - turn - 1;
|
|
217
440
|
if (remaining === 5 && !ctx.finishRequest) {
|
|
218
441
|
messages.push({
|
|
219
442
|
role: 'user',
|
|
220
443
|
content:
|
|
221
|
-
'Only 5 turns remain. Converge now: finish the minimal correct edit set, run run_erc (and run_drc if the board changed), run check_drift, then call finish.',
|
|
444
|
+
'Only 5 turns remain. Converge now: finish the minimal correct edit set, run run_erc (and run_drc if the board changed), run check_drift, then call finish. Batch independent tool calls in a single response (e.g. all resolve_affected calls at once) instead of one per turn.',
|
|
222
445
|
});
|
|
223
446
|
}
|
|
224
447
|
|
|
@@ -228,6 +451,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
228
451
|
if (outcome === 'refuse') {
|
|
229
452
|
await restore(repoRoot, snap);
|
|
230
453
|
await transcript.event('run-refused', { summary });
|
|
454
|
+
const runStats = stats('refused');
|
|
455
|
+
await transcript.event('run-end', runStats);
|
|
231
456
|
await transcript.writeSummary({
|
|
232
457
|
request: opts.request,
|
|
233
458
|
changeId: ctx.changeId,
|
|
@@ -241,9 +466,30 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
241
466
|
outcome: 'aborted',
|
|
242
467
|
openObligations: null,
|
|
243
468
|
detail: `REFUSED: ${summary}`,
|
|
469
|
+
env: meta,
|
|
470
|
+
stats: runStats,
|
|
471
|
+
});
|
|
472
|
+
// Refusals are the most valuable thing to remember: they encode a budget
|
|
473
|
+
// or constraint that this user's designs keep running into.
|
|
474
|
+
await remember({
|
|
475
|
+
request: opts.request,
|
|
476
|
+
outcome: 'refused',
|
|
477
|
+
summary,
|
|
478
|
+
changeId: ctx.changeId,
|
|
479
|
+
filesTouched: [],
|
|
480
|
+
decisions: ctx.decisions,
|
|
481
|
+
verification: 'n/a (refused before verification)',
|
|
244
482
|
});
|
|
245
483
|
log(`refused: ${summary}`);
|
|
246
|
-
|
|
484
|
+
r.finish(outcomeLine(runStats));
|
|
485
|
+
return {
|
|
486
|
+
outcome: 'refused',
|
|
487
|
+
exitPath: 'refused',
|
|
488
|
+
summary,
|
|
489
|
+
transcriptDir: transcript.dir,
|
|
490
|
+
filesTouched: [],
|
|
491
|
+
commit: null,
|
|
492
|
+
};
|
|
247
493
|
}
|
|
248
494
|
|
|
249
495
|
const verification = [
|
|
@@ -262,6 +508,8 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
262
508
|
log(diff || '(no diff)');
|
|
263
509
|
if (untracked) log(`new files:\n${untracked}`);
|
|
264
510
|
await restore(repoRoot, snap);
|
|
511
|
+
const runStats = stats('done');
|
|
512
|
+
await transcript.event('run-end', runStats);
|
|
265
513
|
await transcript.writeSummary({
|
|
266
514
|
request: opts.request,
|
|
267
515
|
changeId: ctx.changeId,
|
|
@@ -275,8 +523,18 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
275
523
|
outcome: 'success',
|
|
276
524
|
openObligations: null,
|
|
277
525
|
detail: 'dry run: changes reverted',
|
|
526
|
+
env: meta,
|
|
527
|
+
stats: runStats,
|
|
278
528
|
});
|
|
279
|
-
|
|
529
|
+
r.finish(outcomeLine(runStats, 'dry run: changes reverted'));
|
|
530
|
+
return {
|
|
531
|
+
outcome: 'success',
|
|
532
|
+
exitPath: 'done',
|
|
533
|
+
summary,
|
|
534
|
+
transcriptDir: transcript.dir,
|
|
535
|
+
filesTouched: files,
|
|
536
|
+
commit: null,
|
|
537
|
+
};
|
|
280
538
|
}
|
|
281
539
|
|
|
282
540
|
await appendChangelog(repoRoot, config, {
|
|
@@ -288,15 +546,33 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
288
546
|
ctx.ledger.clear('changelog');
|
|
289
547
|
|
|
290
548
|
const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
|
|
291
|
-
|
|
549
|
+
// A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
|
|
550
|
+
// must land in summary.md as an outcome, not escape as a stack trace
|
|
551
|
+
// (AC-8.6): roll back per the snapshot contract and report commit-failed.
|
|
552
|
+
let commit: string;
|
|
553
|
+
try {
|
|
554
|
+
commit = await commitAll(repoRoot, commitMsg);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
return fail(`commit failed: ${(err as Error).message}`, 'commit-failed');
|
|
557
|
+
}
|
|
292
558
|
if (ctx.changeId && existsSync(path.join(repoRoot, 'openspec', 'config.yaml'))) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
await
|
|
559
|
+
// The verified commit already exists; discarding it because archive
|
|
560
|
+
// housekeeping failed would be the worse trade, so this is a warning.
|
|
561
|
+
try {
|
|
562
|
+
const arch = await openspecArchive(repoRoot, ctx.changeId);
|
|
563
|
+
await transcript.event('openspec-archive', { changeId: ctx.changeId, ok: arch.ok });
|
|
564
|
+
if (arch.ok && (await isDirty(repoRoot))) {
|
|
565
|
+
await commitAll(repoRoot, `copperhead: archive change ${ctx.changeId}`);
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
const message = (err as Error).message;
|
|
569
|
+
log(`warning: openspec archive failed (${message}); the run commit itself succeeded`);
|
|
570
|
+
await transcript.event('openspec-archive-failed', { changeId: ctx.changeId, error: message });
|
|
297
571
|
}
|
|
298
572
|
}
|
|
299
573
|
await transcript.event('run-committed', { commit, files });
|
|
574
|
+
const runStats = stats('done');
|
|
575
|
+
await transcript.event('run-end', runStats);
|
|
300
576
|
await transcript.writeSummary({
|
|
301
577
|
request: opts.request,
|
|
302
578
|
changeId: ctx.changeId,
|
|
@@ -309,12 +585,34 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
309
585
|
tokensOut,
|
|
310
586
|
outcome: 'success',
|
|
311
587
|
openObligations: null,
|
|
588
|
+
env: meta,
|
|
589
|
+
stats: runStats,
|
|
590
|
+
});
|
|
591
|
+
await remember({
|
|
592
|
+
request: opts.request,
|
|
593
|
+
outcome: 'success',
|
|
594
|
+
summary,
|
|
595
|
+
changeId: ctx.changeId,
|
|
596
|
+
filesTouched: files,
|
|
597
|
+
decisions: ctx.decisions,
|
|
598
|
+
verification,
|
|
312
599
|
});
|
|
313
600
|
log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
|
|
314
|
-
|
|
601
|
+
r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
|
|
602
|
+
return {
|
|
603
|
+
outcome: 'success',
|
|
604
|
+
exitPath: 'done',
|
|
605
|
+
summary,
|
|
606
|
+
transcriptDir: transcript.dir,
|
|
607
|
+
filesTouched: files,
|
|
608
|
+
commit,
|
|
609
|
+
};
|
|
315
610
|
}
|
|
316
611
|
}
|
|
317
612
|
|
|
318
613
|
const filesAfter = await changedFiles(repoRoot, snap.head);
|
|
319
|
-
return fail(
|
|
614
|
+
return fail(
|
|
615
|
+
`turn budget exhausted (${budget} turns, ${filesAfter.length} files touched but unverified)`,
|
|
616
|
+
'turn-budget-exhausted',
|
|
617
|
+
);
|
|
320
618
|
}
|
package/src/agent/prompts.ts
CHANGED
|
@@ -21,7 +21,9 @@ const WORKFLOW = `Workflow for every run:
|
|
|
21
21
|
4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
|
|
22
22
|
5. Run check_drift; update any doc that references a changed value/part/pin in the same run.
|
|
23
23
|
6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
|
|
24
|
-
7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again
|
|
24
|
+
7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.
|
|
25
|
+
|
|
26
|
+
Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn.`;
|
|
25
27
|
|
|
26
28
|
export async function buildSystemPrompt(
|
|
27
29
|
repoRoot: string,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
|
|
2
2
|
|
|
3
|
-
type
|
|
3
|
+
type CacheControl = { cache_control?: { type: 'ephemeral' } };
|
|
4
|
+
type AnthropicContent = (
|
|
4
5
|
| { type: 'text'; text: string }
|
|
5
6
|
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
|
6
|
-
| { type: 'tool_result'; tool_use_id: string; content: string }
|
|
7
|
+
| { type: 'tool_result'; tool_use_id: string; content: string }
|
|
8
|
+
) &
|
|
9
|
+
CacheControl;
|
|
7
10
|
|
|
8
11
|
export class AnthropicProvider implements Provider {
|
|
9
12
|
readonly name = 'anthropic';
|
|
@@ -15,6 +18,13 @@ export class AnthropicProvider implements Provider {
|
|
|
15
18
|
if (!this.apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The loop resends the full conversation every turn, which is quadratic in
|
|
23
|
+
* input tokens. Three ephemeral cache_control breakpoints (system prompt,
|
|
24
|
+
* last tool definition, last block of the final message) cache the stable
|
|
25
|
+
* prefix plus the conversation up to the previous turn, cutting repeated
|
|
26
|
+
* input cost by roughly an order of magnitude on multi-turn runs.
|
|
27
|
+
*/
|
|
18
28
|
async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
|
|
19
29
|
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
20
30
|
const client = new Anthropic({ apiKey: this.apiKey });
|
|
@@ -24,11 +34,11 @@ export class AnthropicProvider implements Provider {
|
|
|
24
34
|
.map((m) => m.content)
|
|
25
35
|
.join('\n\n');
|
|
26
36
|
|
|
27
|
-
const conv: { role: 'user' | 'assistant'; content: AnthropicContent[]
|
|
37
|
+
const conv: { role: 'user' | 'assistant'; content: AnthropicContent[] }[] = [];
|
|
28
38
|
for (const m of messages) {
|
|
29
39
|
if (m.role === 'system') continue;
|
|
30
40
|
if (m.role === 'user') {
|
|
31
|
-
conv.push({ role: 'user', content: m.content });
|
|
41
|
+
conv.push({ role: 'user', content: [{ type: 'text', text: m.content }] });
|
|
32
42
|
} else if (m.role === 'assistant') {
|
|
33
43
|
const content: AnthropicContent[] = [];
|
|
34
44
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
@@ -40,7 +50,7 @@ export class AnthropicProvider implements Provider {
|
|
|
40
50
|
// tool results are user-role content blocks in the Anthropic API
|
|
41
51
|
const prev = conv[conv.length - 1];
|
|
42
52
|
const block: AnthropicContent = { type: 'tool_result', tool_use_id: m.toolCallId, content: m.content };
|
|
43
|
-
if (prev && prev.role === 'user'
|
|
53
|
+
if (prev && prev.role === 'user') {
|
|
44
54
|
prev.content.push(block);
|
|
45
55
|
} else {
|
|
46
56
|
conv.push({ role: 'user', content: [block] });
|
|
@@ -48,20 +58,23 @@ export class AnthropicProvider implements Provider {
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
const lastMsg = conv[conv.length - 1];
|
|
62
|
+
const lastBlock = lastMsg?.content[lastMsg.content.length - 1];
|
|
63
|
+
if (lastBlock) lastBlock.cache_control = { type: 'ephemeral' };
|
|
64
|
+
|
|
65
|
+
const toolDefs = tools.map((t, i) => ({
|
|
66
|
+
name: t.name,
|
|
67
|
+
description: t.description,
|
|
68
|
+
input_schema: t.parameters as never,
|
|
69
|
+
...(i === tools.length - 1 ? { cache_control: { type: 'ephemeral' as const } } : {}),
|
|
70
|
+
}));
|
|
71
|
+
|
|
51
72
|
const res = await client.messages.create({
|
|
52
73
|
model: this.model,
|
|
53
74
|
max_tokens: opts.maxTokens ?? 8192,
|
|
54
|
-
...(system ? { system } : {}),
|
|
75
|
+
...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}),
|
|
55
76
|
messages: conv as never,
|
|
56
|
-
...(tools.length
|
|
57
|
-
? {
|
|
58
|
-
tools: tools.map((t) => ({
|
|
59
|
-
name: t.name,
|
|
60
|
-
description: t.description,
|
|
61
|
-
input_schema: t.parameters as never,
|
|
62
|
-
})),
|
|
63
|
-
}
|
|
64
|
-
: {}),
|
|
77
|
+
...(tools.length ? { tools: toolDefs as never } : {}),
|
|
65
78
|
});
|
|
66
79
|
|
|
67
80
|
let text: string | null = null;
|
|
@@ -72,10 +85,21 @@ export class AnthropicProvider implements Provider {
|
|
|
72
85
|
toolCalls.push({ id: block.id, name: block.name, args: block.input as Record<string, unknown> });
|
|
73
86
|
}
|
|
74
87
|
}
|
|
88
|
+
// input_tokens excludes cached tokens; sum them so the run summary stays
|
|
89
|
+
// honest about volume (the discount shows up on the bill, not here).
|
|
90
|
+
const usage = res.usage as {
|
|
91
|
+
input_tokens: number;
|
|
92
|
+
output_tokens: number;
|
|
93
|
+
cache_read_input_tokens?: number | null;
|
|
94
|
+
cache_creation_input_tokens?: number | null;
|
|
95
|
+
};
|
|
75
96
|
return {
|
|
76
97
|
text,
|
|
77
98
|
toolCalls,
|
|
78
|
-
usage: {
|
|
99
|
+
usage: {
|
|
100
|
+
inputTokens: usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
|
|
101
|
+
outputTokens: usage.output_tokens,
|
|
102
|
+
},
|
|
79
103
|
};
|
|
80
104
|
}
|
|
81
105
|
}
|