copperhead 0.9.0 → 0.11.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 +1 -1
- package/README.md +47 -8
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +63 -72
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +186 -15
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +100 -586
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +181 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +83 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +19 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +135 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +5310 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +504 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +220 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +608 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +431 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +265 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +14 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +13 -8
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +73 -78
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +183 -13
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -628
- package/src/agent/transcript.ts +6 -0
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +154 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +183 -21
- package/src/commands/doctor.ts +85 -6
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +56 -8
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +173 -0
- package/src/kicad/draft/engine.ts +5274 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +549 -0
- package/src/kicad/emit.ts +300 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +695 -0
- package/src/kicad/score.ts +502 -0
- package/src/kicad/sexp.ts +346 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/mcp/server.ts +560 -0
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +14 -1
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
package/src/agent/loop.ts
CHANGED
|
@@ -2,11 +2,13 @@ import path from 'node:path';
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import type { Msg, Provider, Turn } from './types.js';
|
|
5
|
-
import { availableTools,
|
|
5
|
+
import { availableTools, dispatchToolResult, type RunContext } from './tools.js';
|
|
6
|
+
import { flatten } from './envelope.js';
|
|
6
7
|
import { CachingProvider } from './response-cache.js';
|
|
7
|
-
import {
|
|
8
|
+
import { withWatchdog, TurnTimeoutError, MAX_TURN_TIMEOUTS } from './recovery.js';
|
|
8
9
|
import { buildSystemPrompt } from './prompts.js';
|
|
9
10
|
import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
|
|
11
|
+
import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
|
|
10
12
|
import {
|
|
11
13
|
loadConfig,
|
|
12
14
|
CONFIG_DIR,
|
|
@@ -30,7 +32,6 @@ import { AnthropicProvider } from './providers/anthropic.js';
|
|
|
30
32
|
import { CodexProvider } from './providers/codex.js';
|
|
31
33
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
32
34
|
import { CursorProvider } from './providers/cursor.js';
|
|
33
|
-
import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
|
|
34
35
|
|
|
35
36
|
/** What the user sees at the moment they decide whether to keep going. */
|
|
36
37
|
export interface BudgetExhaustedStats {
|
|
@@ -202,16 +203,10 @@ async function appendChangelog(
|
|
|
202
203
|
await writeFile(p, lines.join('\n'), 'utf8');
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
/**
|
|
206
|
-
* Owns the Synap session for one run. The bridge is a subprocess, so the
|
|
207
|
-
* shutdown in `finally` is what lets the CLI exit; without it the process
|
|
208
|
-
* hangs after a successful run.
|
|
209
|
-
*/
|
|
210
206
|
export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
211
|
-
const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
|
|
212
207
|
const providers = new Set<Provider>();
|
|
213
208
|
try {
|
|
214
|
-
return await
|
|
209
|
+
return await runWithProviders(opts, providers);
|
|
215
210
|
} finally {
|
|
216
211
|
for (const provider of providers) {
|
|
217
212
|
try {
|
|
@@ -220,15 +215,10 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
220
215
|
opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
|
|
221
216
|
}
|
|
222
217
|
}
|
|
223
|
-
await memory?.close();
|
|
224
218
|
}
|
|
225
219
|
}
|
|
226
220
|
|
|
227
|
-
async function
|
|
228
|
-
opts: RunOptions,
|
|
229
|
-
memory: SynapMemory | null,
|
|
230
|
-
providers: Set<Provider>,
|
|
231
|
-
): Promise<RunResult> {
|
|
221
|
+
async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Promise<RunResult> {
|
|
232
222
|
const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
|
|
233
223
|
const log = (l: string): void => r.log(l);
|
|
234
224
|
const repoRoot = opts.repoRoot;
|
|
@@ -240,11 +230,28 @@ async function runWithMemory(
|
|
|
240
230
|
|
|
241
231
|
const transcript = new Transcript(repoRoot);
|
|
242
232
|
await transcript.init();
|
|
233
|
+
// Legibility gates finish only where copperhead authored the sheet; a
|
|
234
|
+
// hand-drawn repo gets findings as information, never as a wedge (C6).
|
|
235
|
+
// Both conditions matter: the create-origin marker scopes the gate to repos
|
|
236
|
+
// this tool produced, and the generator stamp scopes it to sheets copperhead
|
|
237
|
+
// still owns. A human taking the sheet over in KiCad re-saves it under
|
|
238
|
+
// KiCad's generator, and from then on the gate must not defend a drawing
|
|
239
|
+
// the engine can no longer regenerate. A create repo whose schematic is not
|
|
240
|
+
// yet scaffolded keeps the gate: the sheet stage 4 will produce is
|
|
241
|
+
// copperhead-authored by construction.
|
|
242
|
+
let gateLegibility = isCreateProducedRepo(config);
|
|
243
|
+
if (gateLegibility && config.schematic) {
|
|
244
|
+
try {
|
|
245
|
+
gateLegibility = isEngineAuthoredSchematic(await readFile(path.join(repoRoot, config.schematic), 'utf8'));
|
|
246
|
+
} catch {
|
|
247
|
+
// schematic configured but absent (pre-scaffold): keep the gate
|
|
248
|
+
}
|
|
249
|
+
}
|
|
243
250
|
const ctx: RunContext = {
|
|
244
251
|
repoRoot,
|
|
245
252
|
config,
|
|
246
253
|
transcript,
|
|
247
|
-
ledger: new ObligationsLedger(),
|
|
254
|
+
ledger: new ObligationsLedger(gateLegibility),
|
|
248
255
|
runId: path.basename(transcript.dir),
|
|
249
256
|
interactive: opts.interactive ?? false,
|
|
250
257
|
confirm: opts.confirm ?? (async () => true),
|
|
@@ -254,6 +261,8 @@ async function runWithMemory(
|
|
|
254
261
|
filesTouched: new Set(),
|
|
255
262
|
decisions: [],
|
|
256
263
|
lastErc: null,
|
|
264
|
+
lastLegibility: null,
|
|
265
|
+
lastScore: null,
|
|
257
266
|
lastDrc: null,
|
|
258
267
|
repairCycles: 0,
|
|
259
268
|
finishRequest: null,
|
|
@@ -328,37 +337,12 @@ async function runWithMemory(
|
|
|
328
337
|
...reopened.map((r) => `- ${r.key} affects ${r.item}`),
|
|
329
338
|
].join('\n');
|
|
330
339
|
}
|
|
331
|
-
// Cross-run memory is appended after the repo's own docs and constraints so
|
|
332
|
-
// that the in-repo sources of truth are what the model reads first.
|
|
333
|
-
const recalled = memory ? await memory.recall(opts.request) : null;
|
|
334
|
-
if (recalled) {
|
|
335
|
-
await transcript.event('synap-recall', { chars: recalled.length });
|
|
336
|
-
log('recalled prior context from Synap memory');
|
|
337
|
-
}
|
|
338
|
-
const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
|
|
339
340
|
const messages: Msg[] = [
|
|
340
|
-
{ role: 'system', content:
|
|
341
|
+
{ role: 'system', content: basePrompt },
|
|
341
342
|
{ role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
|
|
342
343
|
];
|
|
343
344
|
await transcript.event('run-start', meta);
|
|
344
345
|
|
|
345
|
-
/**
|
|
346
|
-
* A memory write that fails is reported rather than swallowed, but it does
|
|
347
|
-
* not change the run's outcome: discarding a verified commit because a
|
|
348
|
-
* third-party write failed would be the worse trade.
|
|
349
|
-
*/
|
|
350
|
-
const remember = async (run: RunRecord): Promise<void> => {
|
|
351
|
-
if (!memory) return;
|
|
352
|
-
try {
|
|
353
|
-
await memory.record(run);
|
|
354
|
-
await transcript.event('synap-record', { outcome: run.outcome });
|
|
355
|
-
} catch (err) {
|
|
356
|
-
const message = (err as Error).message;
|
|
357
|
-
log(`warning: Synap memory write failed (${message}); this run was not recorded`);
|
|
358
|
-
await transcript.event('synap-record-failed', { error: message });
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
|
|
362
346
|
let tokensIn = 0;
|
|
363
347
|
let tokensOut = 0;
|
|
364
348
|
let turnsUsed = 0;
|
|
@@ -366,7 +350,7 @@ async function runWithMemory(
|
|
|
366
350
|
let plan: string | null = null;
|
|
367
351
|
let nudges = 0;
|
|
368
352
|
let turnTimeouts = 0;
|
|
369
|
-
const maxTurnTimeouts =
|
|
353
|
+
const maxTurnTimeouts = MAX_TURN_TIMEOUTS;
|
|
370
354
|
|
|
371
355
|
const stats = (exitPath: ExitPath): RunStats => ({
|
|
372
356
|
exitPath,
|
|
@@ -416,6 +400,8 @@ async function runWithMemory(
|
|
|
416
400
|
filesTouched: [...ctx.filesTouched],
|
|
417
401
|
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : `${ctx.lastErc.violations.length} violations`) : null,
|
|
418
402
|
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : `${ctx.lastDrc.violations.length} violations`) : null,
|
|
403
|
+
legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
|
|
404
|
+
scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
|
|
419
405
|
decisions: ctx.decisions,
|
|
420
406
|
tokensIn,
|
|
421
407
|
tokensOut,
|
|
@@ -515,28 +501,54 @@ async function runWithMemory(
|
|
|
515
501
|
: null;
|
|
516
502
|
heartbeat?.unref?.();
|
|
517
503
|
try {
|
|
504
|
+
// Inactivity watchdog plus hard cap: every onStream call is progress and
|
|
505
|
+
// restarts the idle deadline, so a turn that legitimately runs past
|
|
506
|
+
// turnTimeoutMs survives while it keeps producing output. A provider that
|
|
507
|
+
// never streams gets turnTimeoutMs as a whole-turn deadline, as before.
|
|
518
508
|
res = await withRetry(
|
|
519
509
|
() =>
|
|
520
|
-
|
|
521
|
-
() =>
|
|
522
|
-
|
|
523
|
-
|
|
510
|
+
withWatchdog(
|
|
511
|
+
(activity) =>
|
|
512
|
+
provider.chat(messages, tools, {
|
|
513
|
+
onStream: (chars) => {
|
|
514
|
+
streamedChars = chars;
|
|
515
|
+
activity();
|
|
516
|
+
},
|
|
517
|
+
}),
|
|
518
|
+
{ idleMs: config.turnTimeoutMs, maxMs: config.turnMaxMs, onTimeout: () => provider.close?.() },
|
|
524
519
|
),
|
|
525
520
|
{ onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
|
|
526
521
|
);
|
|
527
522
|
} catch (err) {
|
|
523
|
+
if (err instanceof TurnTimeoutError && err.kind === 'max') {
|
|
524
|
+
// Still producing output at the hard cap: the turn is too large, not
|
|
525
|
+
// hung. Resending the identical request would stream just as long and
|
|
526
|
+
// hit the cap again, so fail now with a reason that says so; the create
|
|
527
|
+
// pipeline hands it to the diagnosis, which can split the work.
|
|
528
|
+
await transcript.event('turn-timeout', { kind: 'max', ms: err.ms, streamedChars });
|
|
529
|
+
return fail(
|
|
530
|
+
`a single provider turn was still producing output after ${fmtDuration(err.ms)} (turnMaxMs) and was stopped — ` +
|
|
531
|
+
'the turn is too large, not hung; split the work into smaller steps, or raise turnMaxMs',
|
|
532
|
+
'provider-error',
|
|
533
|
+
);
|
|
534
|
+
}
|
|
528
535
|
if (err instanceof TurnTimeoutError) {
|
|
529
|
-
// A hung provider turn:
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// the run forever.
|
|
536
|
+
// A hung provider turn: no response or progress for turnTimeoutMs. The
|
|
537
|
+
// watchdog aborted the in-flight call and tore down its subprocess.
|
|
538
|
+
// Retry the same turn a bounded number of times before giving up, so a
|
|
539
|
+
// transient hang self-heals instead of stalling the run forever.
|
|
533
540
|
if (turnTimeouts++ < maxTurnTimeouts) {
|
|
534
|
-
log(
|
|
535
|
-
|
|
541
|
+
log(
|
|
542
|
+
`turn went ${fmtDuration(err.ms)} without a response or progress (turnTimeoutMs); aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`,
|
|
543
|
+
);
|
|
544
|
+
await transcript.event('turn-timeout', { kind: 'idle', ms: err.ms, attempt: turnTimeouts });
|
|
536
545
|
turn--;
|
|
537
546
|
continue;
|
|
538
547
|
}
|
|
539
|
-
return fail(
|
|
548
|
+
return fail(
|
|
549
|
+
`provider turns timed out ${turnTimeouts}× (no response or progress for ${fmtDuration(err.ms)} each)`,
|
|
550
|
+
'provider-error',
|
|
551
|
+
);
|
|
540
552
|
}
|
|
541
553
|
if (isRateLimit(err)) {
|
|
542
554
|
const fallback = otherProvider(provider);
|
|
@@ -605,9 +617,10 @@ async function runWithMemory(
|
|
|
605
617
|
nudges = 0;
|
|
606
618
|
|
|
607
619
|
for (const call of res.toolCalls) {
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
620
|
+
const envelope = await dispatchToolResult(ctx, call.name, call.args, { provider });
|
|
621
|
+
const result = flatten(envelope);
|
|
622
|
+
await transcript.event('tool', { name: call.name, args: call.args, result, envelope });
|
|
623
|
+
r.toolResult(call.name, envelope.summary, envelope.ok, envelope.viewHint);
|
|
611
624
|
messages.push({ role: 'tool', toolCallId: call.id, content: result });
|
|
612
625
|
}
|
|
613
626
|
|
|
@@ -648,17 +661,6 @@ async function runWithMemory(
|
|
|
648
661
|
env: meta,
|
|
649
662
|
stats: runStats,
|
|
650
663
|
});
|
|
651
|
-
// Refusals are the most valuable thing to remember: they encode a budget
|
|
652
|
-
// or constraint that this user's designs keep running into.
|
|
653
|
-
await remember({
|
|
654
|
-
request: opts.request,
|
|
655
|
-
outcome: 'refused',
|
|
656
|
-
summary,
|
|
657
|
-
changeId: ctx.changeId,
|
|
658
|
-
filesTouched: [],
|
|
659
|
-
decisions: ctx.decisions,
|
|
660
|
-
verification: 'n/a (refused before verification)',
|
|
661
|
-
});
|
|
662
664
|
log(`refused: ${summary}`);
|
|
663
665
|
r.finish(outcomeLine(runStats));
|
|
664
666
|
return {
|
|
@@ -777,6 +779,8 @@ async function runWithMemory(
|
|
|
777
779
|
filesTouched: files,
|
|
778
780
|
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
779
781
|
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
782
|
+
legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
|
|
783
|
+
scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
|
|
780
784
|
decisions: ctx.decisions,
|
|
781
785
|
tokensIn,
|
|
782
786
|
tokensOut,
|
|
@@ -785,15 +789,6 @@ async function runWithMemory(
|
|
|
785
789
|
env: meta,
|
|
786
790
|
stats: runStats,
|
|
787
791
|
});
|
|
788
|
-
await remember({
|
|
789
|
-
request: opts.request,
|
|
790
|
-
outcome: 'success',
|
|
791
|
-
summary,
|
|
792
|
-
changeId: ctx.changeId,
|
|
793
|
-
filesTouched: files,
|
|
794
|
-
decisions: ctx.decisions,
|
|
795
|
-
verification,
|
|
796
|
-
});
|
|
797
792
|
log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
|
|
798
793
|
r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
|
|
799
794
|
return {
|
package/src/agent/prompts.ts
CHANGED
|
@@ -19,9 +19,10 @@ const WORKFLOW = `Workflow for every run:
|
|
|
19
19
|
2. Call propose_change with a change id (kebab-case), why, what changes, and tasks. Then call validate_change. Edit tools (edit_file, write_file) unlock only after validation passes.
|
|
20
20
|
3. Make the edits. Use the exact same net names and refdes everywhere. For .kicad_sch/.kicad_pcb use edit_file with unique anchors from the actual file text (read the file first). For renaming a net or refdes across a file, one edit_file call with replace_all: true beats many small edits.
|
|
21
21
|
4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
|
|
22
|
-
5.
|
|
23
|
-
6.
|
|
24
|
-
7.
|
|
22
|
+
5. After schematic edits also run check_legibility and reconcile every error-severity finding (advisories inform, they do not block). An electrically correct sheet that reads badly is not done: finish refuses while error findings are outstanding, the same way it refuses on a failing ERC.
|
|
23
|
+
6. Run check_drift; update any doc that references a changed value/part/pin in the same run.
|
|
24
|
+
7. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
|
|
25
|
+
8. 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
|
|
|
26
27
|
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.
|
|
27
28
|
Always send a populated \`args\` object that matches the tool's JSON Schema (e.g. read_file needs {"path": "..."}). Never open a stage with an empty-args call to probe a tool — it only returns an error and burns a whole turn.`;
|
|
@@ -62,6 +62,11 @@ export interface QueryOptions {
|
|
|
62
62
|
* turns itself instead of us re-sending the whole conversation each turn (1.1,
|
|
63
63
|
* `Options.resume`). Only set in the opt-in session-resume mode. */
|
|
64
64
|
resume?: string;
|
|
65
|
+
/** Emit `stream_event` messages as the model generates, on top of the
|
|
66
|
+
* complete `assistant` message (Agent SDK `Options.includePartialMessages`).
|
|
67
|
+
* Used only as a progress signal for the turn watchdog and heartbeat; the
|
|
68
|
+
* reply is still read from the complete message. */
|
|
69
|
+
includePartialMessages?: boolean;
|
|
65
70
|
}
|
|
66
71
|
export interface QueryArgs {
|
|
67
72
|
prompt: string;
|
|
@@ -73,6 +78,8 @@ export interface QueryMessage {
|
|
|
73
78
|
session_id?: string;
|
|
74
79
|
message?: { content?: Array<{ type: string; text?: string }> };
|
|
75
80
|
usage?: { input_tokens?: number; output_tokens?: number };
|
|
81
|
+
/** On a `stream_event` message: the raw Messages-API stream event. */
|
|
82
|
+
event?: { type: string; delta?: { type: string; text?: string } };
|
|
76
83
|
}
|
|
77
84
|
export type QueryLike = (args: QueryArgs) => AsyncIterable<QueryMessage>;
|
|
78
85
|
|
|
@@ -153,6 +160,8 @@ export class ClaudeCodeProvider implements Provider {
|
|
|
153
160
|
let text: string | null = null;
|
|
154
161
|
let inputTokens = 0;
|
|
155
162
|
let outputTokens = 0;
|
|
163
|
+
// Visible text streamed so far, for the heartbeat's count (see stream_event).
|
|
164
|
+
let streamedChars = 0;
|
|
156
165
|
// One aborter per turn: close() aborts it to kill a hung subprocess.
|
|
157
166
|
const aborter = new AbortController();
|
|
158
167
|
this.inFlight.add(aborter);
|
|
@@ -163,6 +172,10 @@ export class ClaudeCodeProvider implements Provider {
|
|
|
163
172
|
systemPrompt,
|
|
164
173
|
...(this.model ? { model: this.model } : {}),
|
|
165
174
|
abortController: aborter,
|
|
175
|
+
// Partial-message events are the turn's progress signal: without them
|
|
176
|
+
// the SDK yields nothing until the reply is complete, so a turn that is
|
|
177
|
+
// still generating looks exactly like a hung one to the watchdog.
|
|
178
|
+
includePartialMessages: true,
|
|
166
179
|
// Layered "the SDK executes nothing" defense (D1/D5):
|
|
167
180
|
// 1. `tools: []` disables ALL built-in tools (Agent SDK 0.3.x docs:
|
|
168
181
|
// "[] (empty array) - Disable all built-in tools").
|
|
@@ -189,7 +202,15 @@ export class ClaudeCodeProvider implements Provider {
|
|
|
189
202
|
maxTurns: 1,
|
|
190
203
|
},
|
|
191
204
|
})) {
|
|
192
|
-
if (msg.type === '
|
|
205
|
+
if (msg.type === 'stream_event') {
|
|
206
|
+
// Every partial event is progress (thinking and block boundaries too),
|
|
207
|
+
// and reporting it restarts the loop's inactivity watchdog; only text
|
|
208
|
+
// deltas grow the count. The reply itself is still read from the
|
|
209
|
+
// complete `assistant` message below, never reassembled from deltas.
|
|
210
|
+
const delta = msg.event?.delta;
|
|
211
|
+
if (delta?.type === 'text_delta' && delta.text) streamedChars += delta.text.length;
|
|
212
|
+
opts.onStream?.(streamedChars);
|
|
213
|
+
} else if (msg.type === 'assistant') {
|
|
193
214
|
for (const block of msg.message?.content ?? []) {
|
|
194
215
|
if (block.type === 'text' && block.text) {
|
|
195
216
|
text = (text ?? '') + block.text;
|
|
@@ -15,7 +15,7 @@ type CodexThreadOptions = Pick<
|
|
|
15
15
|
| 'networkAccessEnabled'
|
|
16
16
|
| 'webSearchMode'
|
|
17
17
|
>;
|
|
18
|
-
type CodexTurnOptions = Pick<TurnOptions, 'outputSchema'>;
|
|
18
|
+
type CodexTurnOptions = Pick<TurnOptions, 'outputSchema' | 'signal'>;
|
|
19
19
|
|
|
20
20
|
interface CodexTurnLike {
|
|
21
21
|
finalResponse: string;
|
|
@@ -58,6 +58,13 @@ export class CodexProvider implements Provider {
|
|
|
58
58
|
private readonly client: CodexClientLike;
|
|
59
59
|
private thread: CodexThreadLike | null = null;
|
|
60
60
|
private messageCursor = 0;
|
|
61
|
+
/** In-flight turn aborters, so close() (called by the turn watchdog on a hung
|
|
62
|
+
* turn) kills the `codex exec` subprocess instead of orphaning it. */
|
|
63
|
+
private readonly inFlight = new Set<AbortController>();
|
|
64
|
+
/** Bumped by close(). A turn begun under an earlier generation was abandoned;
|
|
65
|
+
* if it settles late it must not touch the thread or cursor that replaced it,
|
|
66
|
+
* or it would mark messages seen that the fresh thread never received. */
|
|
67
|
+
private generation = 0;
|
|
61
68
|
|
|
62
69
|
constructor(options: CodexProviderOptions) {
|
|
63
70
|
this.model = options.model;
|
|
@@ -67,53 +74,88 @@ export class CodexProvider implements Provider {
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
async chat(messages: Msg[], tools: ToolSchema[], _opts: ChatOpts = {}): Promise<Turn> {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
...(this.model ? { model: this.model } : {}),
|
|
74
|
-
workingDirectory,
|
|
75
|
-
skipGitRepoCheck: true,
|
|
76
|
-
sandboxMode: 'read-only',
|
|
77
|
-
approvalPolicy: 'never',
|
|
78
|
-
networkAccessEnabled: false,
|
|
79
|
-
webSearchMode: 'disabled',
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const cursor = this.messageCursor;
|
|
84
|
-
const schema = turnSchema(tools);
|
|
85
|
-
const toolCatalog = new Map(tools.map((tool) => [tool.name, tool]));
|
|
86
|
-
const attempts: CodexTurnLike[] = [];
|
|
87
|
-
let result = await this.runThread(renderTurnPrompt(messages, cursor, tools), schema);
|
|
88
|
-
attempts.push(result);
|
|
89
|
-
|
|
90
|
-
let parsed: ReturnType<typeof parseStructuredTurn>;
|
|
77
|
+
const generation = this.generation;
|
|
78
|
+
const aborter = new AbortController();
|
|
79
|
+
this.inFlight.add(aborter);
|
|
91
80
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
81
|
+
const workingDirectory = await this.ensureWorkingDirectory();
|
|
82
|
+
this.assertCurrent(generation);
|
|
83
|
+
if (!this.thread) {
|
|
84
|
+
this.thread = this.client.startThread({
|
|
85
|
+
...(this.model ? { model: this.model } : {}),
|
|
86
|
+
workingDirectory,
|
|
87
|
+
skipGitRepoCheck: true,
|
|
88
|
+
sandboxMode: 'read-only',
|
|
89
|
+
approvalPolicy: 'never',
|
|
90
|
+
networkAccessEnabled: false,
|
|
91
|
+
webSearchMode: 'disabled',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const thread = this.thread;
|
|
95
|
+
|
|
96
|
+
const cursor = this.messageCursor;
|
|
97
|
+
const schema = turnSchema(tools);
|
|
98
|
+
const toolCatalog = new Map(tools.map((tool) => [tool.name, tool]));
|
|
99
|
+
const attempts: CodexTurnLike[] = [];
|
|
100
|
+
let result = await this.runThread(thread, renderTurnPrompt(messages, cursor, tools), schema, aborter.signal);
|
|
101
|
+
this.assertCurrent(generation);
|
|
96
102
|
attempts.push(result);
|
|
97
|
-
parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
|
|
98
|
-
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
let parsed: ReturnType<typeof parseStructuredTurn>;
|
|
105
|
+
try {
|
|
106
|
+
parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const validationError = (err as Error).message;
|
|
109
|
+
result = await this.runThread(thread, renderCorrectionPrompt(tools, validationError), schema, aborter.signal);
|
|
110
|
+
this.assertCurrent(generation);
|
|
111
|
+
attempts.push(result);
|
|
112
|
+
parsed = parseStructuredTurn(result.finalResponse, toolCatalog);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The input remains unseen until Copperhead accepts a structured turn.
|
|
116
|
+
this.messageCursor = messages.length;
|
|
117
|
+
return {
|
|
118
|
+
text: parsed.text.trim() || null,
|
|
119
|
+
toolCalls: parsed.toolCalls,
|
|
120
|
+
usage: {
|
|
121
|
+
inputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.input_tokens ?? 0), 0),
|
|
122
|
+
outputTokens: attempts.reduce((sum, attempt) => sum + (attempt.usage?.output_tokens ?? 0), 0),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
} finally {
|
|
126
|
+
this.inFlight.delete(aborter);
|
|
127
|
+
}
|
|
110
128
|
}
|
|
111
129
|
|
|
112
130
|
async close(): Promise<void> {
|
|
131
|
+
this.generation++;
|
|
132
|
+
for (const aborter of this.inFlight) {
|
|
133
|
+
try {
|
|
134
|
+
aborter.abort();
|
|
135
|
+
} catch {
|
|
136
|
+
// best effort: a turn that already settled has nothing to tear down
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.inFlight.clear();
|
|
140
|
+
// A fresh thread has seen nothing, so the next turn must send the full
|
|
141
|
+
// history (system prompt and request included), not the delta meant for the
|
|
142
|
+
// thread being discarded; otherwise a retried turn runs without context.
|
|
113
143
|
this.thread = null;
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
this.messageCursor = 0;
|
|
145
|
+
// Forget the directory before deleting it: the watchdog does not await
|
|
146
|
+
// close(), so a retried turn can start while rm is still running, and it must
|
|
147
|
+
// create a fresh directory rather than reuse the one being deleted.
|
|
148
|
+
const dir = this.workingDirectory;
|
|
149
|
+
if (this.ownsWorkingDirectory && dir) {
|
|
116
150
|
this.workingDirectory = null;
|
|
151
|
+
await rm(dir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Throw if close() ran since the turn began (see `generation`). */
|
|
156
|
+
private assertCurrent(generation: number): void {
|
|
157
|
+
if (generation !== this.generation) {
|
|
158
|
+
throw new Error('codex: turn abandoned because the provider was closed while it ran');
|
|
117
159
|
}
|
|
118
160
|
}
|
|
119
161
|
|
|
@@ -126,9 +168,16 @@ export class CodexProvider implements Provider {
|
|
|
126
168
|
return this.workingDirectory;
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
private async runThread(
|
|
171
|
+
private async runThread(
|
|
172
|
+
thread: CodexThreadLike,
|
|
173
|
+
prompt: string,
|
|
174
|
+
outputSchema: Record<string, unknown>,
|
|
175
|
+
signal: AbortSignal,
|
|
176
|
+
): Promise<CodexTurnLike> {
|
|
130
177
|
try {
|
|
131
|
-
|
|
178
|
+
// The turn's own thread, not `this.thread`, which close() may have
|
|
179
|
+
// replaced; the signal lets close() kill the `codex exec` subprocess.
|
|
180
|
+
return await thread.run(prompt, { outputSchema, signal });
|
|
132
181
|
} catch (err) {
|
|
133
182
|
const original = err as Error & { status?: number; statusCode?: number };
|
|
134
183
|
const setupHint = isCliSetupError(original)
|
|
@@ -85,9 +85,14 @@ export interface ParsedToolTurn {
|
|
|
85
85
|
* split so only an inner `{args}` with no `tool` key balanced), not the tool
|
|
86
86
|
* being broken. Returns a one-line steer to re-emit it, or undefined when the
|
|
87
87
|
* absence of a call is genuine (plain prose, no tool named).
|
|
88
|
+
*
|
|
89
|
+
* Also covers the mirror case (I18): well-formed JSON naming a tool that is NOT in
|
|
90
|
+
* the catalog — withheld by the edit lock, or invented. That call is correctly not
|
|
91
|
+
* dispatched, but it must not be silent either.
|
|
88
92
|
*/
|
|
89
93
|
function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
|
|
90
94
|
const re = /"tool"\s*:\s*"([^"]+)"/g;
|
|
95
|
+
const offCatalog: string[] = [];
|
|
91
96
|
let m: RegExpExecArray | null;
|
|
92
97
|
while ((m = re.exec(text)) !== null) {
|
|
93
98
|
const name = m[1]!;
|
|
@@ -98,6 +103,23 @@ function detectMalformedCall(text: string, catalog: Set<string>): string | undef
|
|
|
98
103
|
'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
|
|
99
104
|
);
|
|
100
105
|
}
|
|
106
|
+
if (!offCatalog.includes(name)) offCatalog.push(name);
|
|
107
|
+
}
|
|
108
|
+
// A well-formed call naming a tool the turn did not advertise — a locked edit or
|
|
109
|
+
// drafting tool before propose_change/validate_change, or an invented name.
|
|
110
|
+
// `toToolCall` is right to refuse it (the lock is structural, D2), but dropping it
|
|
111
|
+
// to prose tells the model NOTHING, and it fills that silence: fabricating the
|
|
112
|
+
// result it never got (I15), or concluding the engine is absent from the build and
|
|
113
|
+
// refusing the stage outright (I18). Withholding the tool is the invariant; hiding
|
|
114
|
+
// the reason never was, so name it and print the real catalog.
|
|
115
|
+
if (offCatalog.length) {
|
|
116
|
+
const named = offCatalog.map((n) => `"${n}"`).join(', ');
|
|
117
|
+
return (
|
|
118
|
+
`No call ran: ${named} ${offCatalog.length > 1 ? 'are' : 'is'} not in this turn's tool ` +
|
|
119
|
+
'catalog. Edit and drafting tools are withheld until a proposal validates — call ' +
|
|
120
|
+
'propose_change, then validate_change, and they appear. Do not conclude a tool is ' +
|
|
121
|
+
`missing from the build. Available this turn: ${[...catalog].join(', ')}.`
|
|
122
|
+
);
|
|
101
123
|
}
|
|
102
124
|
return undefined;
|
|
103
125
|
}
|