copperhead 0.8.1 → 0.10.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 +13 -5
- 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 +67 -62
- 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/openai.js +28 -6
- package/dist/agent/providers/openai.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 +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/response-cache.js +18 -2
- package/dist/agent/response-cache.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- 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/cli.js +77 -2
- 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 +282 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +211 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +61 -4
- 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 +7 -26
- 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 +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -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 +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -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 +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +262 -10
- 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/memory/bom-table.js +108 -34
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/openspec/cli.js +2 -1
- package/dist/openspec/cli.js.map +1 -1
- package/dist/util/preflight.js +17 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/redact.js +12 -2
- package/dist/util/redact.js.map +1 -1
- package/package.json +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +88 -65
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/openai.ts +38 -4
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/response-cache.ts +17 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +73 -2
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +278 -22
- package/src/commands/doctor.ts +219 -12
- package/src/config.ts +107 -2
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +6 -19
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +339 -10
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +119 -31
- package/src/memory/scaffold.ts +6 -0
- package/src/openspec/cli.ts +3 -2
- package/src/util/preflight.ts +18 -0
- package/src/util/redact.ts +12 -2
- 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
|
@@ -7,7 +7,16 @@ import { CachingProvider } from './response-cache.js';
|
|
|
7
7
|
import { withTimeout, TurnTimeoutError } from './recovery.js';
|
|
8
8
|
import { buildSystemPrompt } from './prompts.js';
|
|
9
9
|
import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
|
|
10
|
-
import {
|
|
10
|
+
import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
|
|
11
|
+
import {
|
|
12
|
+
loadConfig,
|
|
13
|
+
CONFIG_DIR,
|
|
14
|
+
DEFAULT_API_KEY_ENV,
|
|
15
|
+
resolveCompatSettings,
|
|
16
|
+
isCompatModel,
|
|
17
|
+
type CompatSettings,
|
|
18
|
+
type CopperheadConfig,
|
|
19
|
+
} from '../config.js';
|
|
11
20
|
import { Transcript, type ExitPath, type RunStats } from './transcript.js';
|
|
12
21
|
import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
|
|
13
22
|
import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
|
|
@@ -22,7 +31,6 @@ import { AnthropicProvider } from './providers/anthropic.js';
|
|
|
22
31
|
import { CodexProvider } from './providers/codex.js';
|
|
23
32
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
24
33
|
import { CursorProvider } from './providers/cursor.js';
|
|
25
|
-
import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
|
|
26
34
|
|
|
27
35
|
/** What the user sees at the moment they decide whether to keep going. */
|
|
28
36
|
export interface BudgetExhaustedStats {
|
|
@@ -74,7 +82,43 @@ export interface RunResult {
|
|
|
74
82
|
cacheHits: number;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
export async function makeProvider(
|
|
85
|
+
export async function makeProvider(
|
|
86
|
+
model: string,
|
|
87
|
+
sessionResume = false,
|
|
88
|
+
compat?: CompatSettings | undefined,
|
|
89
|
+
): Promise<Provider> {
|
|
90
|
+
// OpenAI-compatible endpoint (Groq, OpenRouter, Gemini compat, local
|
|
91
|
+
// Ollama). An explicit `compat` prefix is the opt-in: nothing else
|
|
92
|
+
// consults baseURL, so a stray COPPERHEAD_BASE_URL cannot redirect a keyed
|
|
93
|
+
// `gpt-5` run to a third party (design D2).
|
|
94
|
+
if (isCompatModel(model)) {
|
|
95
|
+
const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : undefined;
|
|
96
|
+
if (compatModel === '') {
|
|
97
|
+
throw new Error('compat model override cannot be empty; use "compat:<model-id>"');
|
|
98
|
+
}
|
|
99
|
+
const settings = compat ?? { apiKeyEnv: DEFAULT_API_KEY_ENV };
|
|
100
|
+
// Bare `compat` (no id) has no valid default: unlike gpt-5/claude, a
|
|
101
|
+
// compatible endpoint serves whatever models its host chooses, so there is
|
|
102
|
+
// no id that is ever correct to assume. Falling through here would build a
|
|
103
|
+
// provider that silently sends the literal string "gpt-5" (OpenAIProvider's
|
|
104
|
+
// own default) to a host that almost certainly does not serve it.
|
|
105
|
+
if (!compatModel) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
settings.baseURL
|
|
108
|
+
? `compat requires a model id; use "compat:<model-id>" (endpoint ${settings.baseURL} is configured, but has no default model)`
|
|
109
|
+
: 'compat requires a model id and an endpoint; use "compat:<model-id>" and set baseURL (COPPERHEAD_BASE_URL or .copperhead/config.json)',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (!settings.baseURL) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`compat:${compatModel} requires an endpoint; set baseURL (COPPERHEAD_BASE_URL or "baseURL" in .copperhead/config.json) — without one this would silently fall back to the real OpenAI API.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return new OpenAIProvider(compatModel, {
|
|
118
|
+
baseURL: settings.baseURL,
|
|
119
|
+
apiKeyEnv: settings.apiKeyEnv,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
78
122
|
if (model === 'codex' || model.startsWith('codex:')) {
|
|
79
123
|
const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
|
|
80
124
|
if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
|
|
@@ -158,16 +202,10 @@ async function appendChangelog(
|
|
|
158
202
|
await writeFile(p, lines.join('\n'), 'utf8');
|
|
159
203
|
}
|
|
160
204
|
|
|
161
|
-
/**
|
|
162
|
-
* Owns the Synap session for one run. The bridge is a subprocess, so the
|
|
163
|
-
* shutdown in `finally` is what lets the CLI exit; without it the process
|
|
164
|
-
* hangs after a successful run.
|
|
165
|
-
*/
|
|
166
205
|
export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
167
|
-
const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
|
|
168
206
|
const providers = new Set<Provider>();
|
|
169
207
|
try {
|
|
170
|
-
return await
|
|
208
|
+
return await runWithProviders(opts, providers);
|
|
171
209
|
} finally {
|
|
172
210
|
for (const provider of providers) {
|
|
173
211
|
try {
|
|
@@ -176,15 +214,10 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
|
|
|
176
214
|
opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
|
|
177
215
|
}
|
|
178
216
|
}
|
|
179
|
-
await memory?.close();
|
|
180
217
|
}
|
|
181
218
|
}
|
|
182
219
|
|
|
183
|
-
async function
|
|
184
|
-
opts: RunOptions,
|
|
185
|
-
memory: SynapMemory | null,
|
|
186
|
-
providers: Set<Provider>,
|
|
187
|
-
): Promise<RunResult> {
|
|
220
|
+
async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Promise<RunResult> {
|
|
188
221
|
const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
|
|
189
222
|
const log = (l: string): void => r.log(l);
|
|
190
223
|
const repoRoot = opts.repoRoot;
|
|
@@ -196,11 +229,28 @@ async function runWithMemory(
|
|
|
196
229
|
|
|
197
230
|
const transcript = new Transcript(repoRoot);
|
|
198
231
|
await transcript.init();
|
|
232
|
+
// Legibility gates finish only where copperhead authored the sheet; a
|
|
233
|
+
// hand-drawn repo gets findings as information, never as a wedge (C6).
|
|
234
|
+
// Both conditions matter: the create-origin marker scopes the gate to repos
|
|
235
|
+
// this tool produced, and the generator stamp scopes it to sheets copperhead
|
|
236
|
+
// still owns. A human taking the sheet over in KiCad re-saves it under
|
|
237
|
+
// KiCad's generator, and from then on the gate must not defend a drawing
|
|
238
|
+
// the engine can no longer regenerate. A create repo whose schematic is not
|
|
239
|
+
// yet scaffolded keeps the gate: the sheet stage 4 will produce is
|
|
240
|
+
// copperhead-authored by construction.
|
|
241
|
+
let gateLegibility = isCreateProducedRepo(config);
|
|
242
|
+
if (gateLegibility && config.schematic) {
|
|
243
|
+
try {
|
|
244
|
+
gateLegibility = isEngineAuthoredSchematic(await readFile(path.join(repoRoot, config.schematic), 'utf8'));
|
|
245
|
+
} catch {
|
|
246
|
+
// schematic configured but absent (pre-scaffold): keep the gate
|
|
247
|
+
}
|
|
248
|
+
}
|
|
199
249
|
const ctx: RunContext = {
|
|
200
250
|
repoRoot,
|
|
201
251
|
config,
|
|
202
252
|
transcript,
|
|
203
|
-
ledger: new ObligationsLedger(),
|
|
253
|
+
ledger: new ObligationsLedger(gateLegibility),
|
|
204
254
|
runId: path.basename(transcript.dir),
|
|
205
255
|
interactive: opts.interactive ?? false,
|
|
206
256
|
confirm: opts.confirm ?? (async () => true),
|
|
@@ -210,6 +260,8 @@ async function runWithMemory(
|
|
|
210
260
|
filesTouched: new Set(),
|
|
211
261
|
decisions: [],
|
|
212
262
|
lastErc: null,
|
|
263
|
+
lastLegibility: null,
|
|
264
|
+
lastScore: null,
|
|
213
265
|
lastDrc: null,
|
|
214
266
|
repairCycles: 0,
|
|
215
267
|
finishRequest: null,
|
|
@@ -220,12 +272,24 @@ async function runWithMemory(
|
|
|
220
272
|
// it only when the env flag is set AND config.llmCache is disabled — the same
|
|
221
273
|
// condition under which we skip the CachingProvider wrap below.
|
|
222
274
|
const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
|
|
223
|
-
|
|
275
|
+
const compatSettings = resolveCompatSettings(config);
|
|
276
|
+
let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume, compatSettings));
|
|
224
277
|
// Cache every turn's response so a retried/restarted stage replays what it
|
|
225
278
|
// already paid for instead of re-calling the model (repo-scoped, cross-run).
|
|
226
279
|
// Skip an injected provider (tests drive scripted providers directly).
|
|
227
280
|
if (config.llmCache && !opts.provider) {
|
|
228
|
-
|
|
281
|
+
// Mirrors makeProvider's own gate above (D2/AC-3.16): COPPERHEAD_BASE_URL
|
|
282
|
+
// is consulted only for the explicit `compat:` route, so a gpt-5/claude
|
|
283
|
+
// run's cache key must not vary with a variable that run never reads —
|
|
284
|
+
// otherwise every non-compat cache entry gets orphaned each time the
|
|
285
|
+
// endpoint used for compat testing changes.
|
|
286
|
+
provider = new CachingProvider(
|
|
287
|
+
provider,
|
|
288
|
+
path.join(repoRoot, CONFIG_DIR, 'llm-cache'),
|
|
289
|
+
log,
|
|
290
|
+
opts.model,
|
|
291
|
+
isCompatModel(opts.model) ? compatSettings.baseURL : undefined,
|
|
292
|
+
);
|
|
229
293
|
}
|
|
230
294
|
providers.add(provider);
|
|
231
295
|
// Held separately from `provider` (which is reassigned on failover) so the
|
|
@@ -272,37 +336,12 @@ async function runWithMemory(
|
|
|
272
336
|
...reopened.map((r) => `- ${r.key} affects ${r.item}`),
|
|
273
337
|
].join('\n');
|
|
274
338
|
}
|
|
275
|
-
// Cross-run memory is appended after the repo's own docs and constraints so
|
|
276
|
-
// that the in-repo sources of truth are what the model reads first.
|
|
277
|
-
const recalled = memory ? await memory.recall(opts.request) : null;
|
|
278
|
-
if (recalled) {
|
|
279
|
-
await transcript.event('synap-recall', { chars: recalled.length });
|
|
280
|
-
log('recalled prior context from Synap memory');
|
|
281
|
-
}
|
|
282
|
-
const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
|
|
283
339
|
const messages: Msg[] = [
|
|
284
|
-
{ role: 'system', content:
|
|
340
|
+
{ role: 'system', content: basePrompt },
|
|
285
341
|
{ role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
|
|
286
342
|
];
|
|
287
343
|
await transcript.event('run-start', meta);
|
|
288
344
|
|
|
289
|
-
/**
|
|
290
|
-
* A memory write that fails is reported rather than swallowed, but it does
|
|
291
|
-
* not change the run's outcome: discarding a verified commit because a
|
|
292
|
-
* third-party write failed would be the worse trade.
|
|
293
|
-
*/
|
|
294
|
-
const remember = async (run: RunRecord): Promise<void> => {
|
|
295
|
-
if (!memory) return;
|
|
296
|
-
try {
|
|
297
|
-
await memory.record(run);
|
|
298
|
-
await transcript.event('synap-record', { outcome: run.outcome });
|
|
299
|
-
} catch (err) {
|
|
300
|
-
const message = (err as Error).message;
|
|
301
|
-
log(`warning: Synap memory write failed (${message}); this run was not recorded`);
|
|
302
|
-
await transcript.event('synap-record-failed', { error: message });
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
|
|
306
345
|
let tokensIn = 0;
|
|
307
346
|
let tokensOut = 0;
|
|
308
347
|
let turnsUsed = 0;
|
|
@@ -360,6 +399,8 @@ async function runWithMemory(
|
|
|
360
399
|
filesTouched: [...ctx.filesTouched],
|
|
361
400
|
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : `${ctx.lastErc.violations.length} violations`) : null,
|
|
362
401
|
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : `${ctx.lastDrc.violations.length} violations`) : null,
|
|
402
|
+
legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
|
|
403
|
+
scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
|
|
363
404
|
decisions: ctx.decisions,
|
|
364
405
|
tokensIn,
|
|
365
406
|
tokensOut,
|
|
@@ -592,17 +633,6 @@ async function runWithMemory(
|
|
|
592
633
|
env: meta,
|
|
593
634
|
stats: runStats,
|
|
594
635
|
});
|
|
595
|
-
// Refusals are the most valuable thing to remember: they encode a budget
|
|
596
|
-
// or constraint that this user's designs keep running into.
|
|
597
|
-
await remember({
|
|
598
|
-
request: opts.request,
|
|
599
|
-
outcome: 'refused',
|
|
600
|
-
summary,
|
|
601
|
-
changeId: ctx.changeId,
|
|
602
|
-
filesTouched: [],
|
|
603
|
-
decisions: ctx.decisions,
|
|
604
|
-
verification: 'n/a (refused before verification)',
|
|
605
|
-
});
|
|
606
636
|
log(`refused: ${summary}`);
|
|
607
637
|
r.finish(outcomeLine(runStats));
|
|
608
638
|
return {
|
|
@@ -721,6 +751,8 @@ async function runWithMemory(
|
|
|
721
751
|
filesTouched: files,
|
|
722
752
|
ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
723
753
|
drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : 'FAILING') : 'not run',
|
|
754
|
+
legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
|
|
755
|
+
scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
|
|
724
756
|
decisions: ctx.decisions,
|
|
725
757
|
tokensIn,
|
|
726
758
|
tokensOut,
|
|
@@ -729,15 +761,6 @@ async function runWithMemory(
|
|
|
729
761
|
env: meta,
|
|
730
762
|
stats: runStats,
|
|
731
763
|
});
|
|
732
|
-
await remember({
|
|
733
|
-
request: opts.request,
|
|
734
|
-
outcome: 'success',
|
|
735
|
-
summary,
|
|
736
|
-
changeId: ctx.changeId,
|
|
737
|
-
filesTouched: files,
|
|
738
|
-
decisions: ctx.decisions,
|
|
739
|
-
verification,
|
|
740
|
-
});
|
|
741
764
|
log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
|
|
742
765
|
r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
|
|
743
766
|
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.`;
|
|
@@ -1,18 +1,52 @@
|
|
|
1
|
+
import { DEFAULT_API_KEY_ENV, isLocalEndpoint } from '../../config.js';
|
|
1
2
|
import type { ChatOpts, Msg, Provider, ToolSchema, Turn, ToolCall } from '../types.js';
|
|
2
3
|
|
|
4
|
+
/** Pointing the provider at an OpenAI-compatible endpoint (design D1). */
|
|
5
|
+
export interface OpenAIProviderOptions {
|
|
6
|
+
/** Endpoint base URL; omitted means the client's own default (OpenAI). */
|
|
7
|
+
baseURL?: string | undefined;
|
|
8
|
+
/** Name of the env var holding the key. Never the key itself. */
|
|
9
|
+
apiKeyEnv?: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export class OpenAIProvider implements Provider {
|
|
4
|
-
readonly name
|
|
13
|
+
readonly name: string;
|
|
14
|
+
private readonly apiKey: string | undefined;
|
|
15
|
+
private readonly baseURL: string | undefined;
|
|
5
16
|
|
|
6
17
|
constructor(
|
|
7
18
|
private readonly model = 'gpt-5',
|
|
8
|
-
|
|
19
|
+
opts: OpenAIProviderOptions = {},
|
|
20
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
9
21
|
) {
|
|
10
|
-
|
|
22
|
+
// Credentials are always resolved through a named env var, never accepted
|
|
23
|
+
// as a literal value: the one way to supply a key keeps application code
|
|
24
|
+
// from ever holding one directly (mirrors AC-4.1 elsewhere). Tests inject
|
|
25
|
+
// fake values through the `env` argument, not through opts.
|
|
26
|
+
const keyEnv = opts.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
|
|
27
|
+
this.baseURL = opts.baseURL;
|
|
28
|
+
// A compat endpoint must be structurally ineligible for the paid
|
|
29
|
+
// OpenAI/Anthropic failover in otherProvider() (loop.ts) — it is not
|
|
30
|
+
// OpenAI, and a rate limit there must never silently redirect a run the
|
|
31
|
+
// user deliberately pointed elsewhere to someone else's paid API.
|
|
32
|
+
this.name = this.baseURL ? 'openai-compat' : 'openai';
|
|
33
|
+
this.apiKey = env[keyEnv];
|
|
34
|
+
// A loopback endpoint (Ollama) serves the same API with no credential, and
|
|
35
|
+
// it is the one backend that is both free and fully local — requiring a
|
|
36
|
+
// dummy key there would be a papercut on the most useful config (D4).
|
|
37
|
+
if (!this.apiKey && !isLocalEndpoint(this.baseURL)) {
|
|
38
|
+
throw new Error(`${keyEnv} is not set`);
|
|
39
|
+
}
|
|
11
40
|
}
|
|
12
41
|
|
|
13
42
|
async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
|
|
14
43
|
const { default: OpenAI } = await import('openai');
|
|
15
|
-
const client = new OpenAI({
|
|
44
|
+
const client = new OpenAI({
|
|
45
|
+
// A local endpoint may legitimately have no key, but the client still
|
|
46
|
+
// wants a non-empty string, so send a placeholder it will never check.
|
|
47
|
+
apiKey: this.apiKey ?? 'no-key-required',
|
|
48
|
+
...(this.baseURL ? { baseURL: this.baseURL } : {}),
|
|
49
|
+
});
|
|
16
50
|
const res = await client.chat.completions.create({
|
|
17
51
|
model: this.model,
|
|
18
52
|
max_completion_tokens: opts.maxTokens ?? 8192,
|
|
@@ -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
|
}
|
package/src/agent/recovery.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import type { Msg, Provider } from './types.js';
|
|
5
|
+
import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
|
|
5
6
|
|
|
6
7
|
/** Thrown when a single provider turn blows past its watchdog deadline. */
|
|
7
8
|
export class TurnTimeoutError extends Error {
|
|
@@ -116,6 +117,91 @@ export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000):
|
|
|
116
117
|
return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Deterministically re-probe every lib_id named in a failure narrative against
|
|
122
|
+
* the installed libraries, so the diagnostician judges symbol-availability
|
|
123
|
+
* claims from machine facts instead of the agent's prose. An agent that has
|
|
124
|
+
* been dead-ended by wrong library nicknames concludes — and records — that
|
|
125
|
+
* whole libraries are absent when they are installed; a refusal built on that
|
|
126
|
+
* premise reads exactly like a genuine environmental gap, and the one thing
|
|
127
|
+
* that distinguishes them is re-checking the named lib_ids, which costs no
|
|
128
|
+
* LLM turn. Never throws; on any probe error it reports what it could.
|
|
129
|
+
*/
|
|
130
|
+
export async function symbolAvailabilityFacts(text: string, dirs?: string[], cap = 8): Promise<string> {
|
|
131
|
+
const ids: string[] = [];
|
|
132
|
+
// A library nickname is its `.kicad_sym` filename stem, so it can carry `-`
|
|
133
|
+
// and `.` as well as `_` (`Custom-Parts`, `MyCorp.RF`) — a nickname the regex
|
|
134
|
+
// truncates is probed as the wrong lib_id and reported absent, which is the
|
|
135
|
+
// false negative this whole fact block exists to prevent. Separators are
|
|
136
|
+
// interior only, so a trailing sentence period is not swallowed.
|
|
137
|
+
for (const m of text.matchAll(/\b([A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?):([A-Za-z0-9][A-Za-z0-9_.+-]*)/g)) {
|
|
138
|
+
const lib = m[1]!;
|
|
139
|
+
const name = m[2]!;
|
|
140
|
+
// Require letters on both sides: drops file:line refs ("create.ts:311"),
|
|
141
|
+
// times and bare numbers. Engine-generated power symbols are not library
|
|
142
|
+
// facts.
|
|
143
|
+
if (!/[A-Za-z]/.test(lib) || !/[A-Za-z]/.test(name) || lib === 'copperhead_power') continue;
|
|
144
|
+
const libId = `${lib}:${name}`;
|
|
145
|
+
if (!ids.includes(libId)) ids.push(libId);
|
|
146
|
+
}
|
|
147
|
+
if (!ids.length) return '';
|
|
148
|
+
// Collection is unbounded but probing is capped, so a probe-heavy transcript
|
|
149
|
+
// stays cheap. The overflow is named rather than dropped: the supervisor is
|
|
150
|
+
// told these facts are ground truth, and silently probing 8 of 30 lib_ids
|
|
151
|
+
// would let it read "unprobed" as "absent".
|
|
152
|
+
const probed = ids.slice(0, cap);
|
|
153
|
+
const unprobed = ids.slice(cap);
|
|
154
|
+
let searchDirs: string[];
|
|
155
|
+
try {
|
|
156
|
+
searchDirs = dirs ?? (await symbolSearchDirs());
|
|
157
|
+
} catch {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
if (!searchDirs.length) return '';
|
|
161
|
+
// A directory with no readable library means nothing was checked: emitting
|
|
162
|
+
// "not installed" lines as ground truth from that state is the exact false
|
|
163
|
+
// absence this block exists to prevent (same guard as bomSymbolDossier).
|
|
164
|
+
if (!(await listInstalledLibraries(searchDirs)).size) return '';
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
for (const libId of probed) {
|
|
167
|
+
const name = libId.slice(libId.indexOf(':') + 1);
|
|
168
|
+
try {
|
|
169
|
+
const r = await resolveLibrarySymbol(libId, searchDirs);
|
|
170
|
+
if (r.status === 'ok') {
|
|
171
|
+
lines.push(`- ${libId}: RESOLVES on this machine (${r.pins.length} pins)`);
|
|
172
|
+
} else if (r.status === 'found-elsewhere') {
|
|
173
|
+
// The resolver already located the part under another lib_id; saying
|
|
174
|
+
// "not installed" here would be the exact false absence claim this
|
|
175
|
+
// block exists to prevent.
|
|
176
|
+
lines.push(`- ${libId}: not at that lib_id, but installed as: ${r.libIds.slice(0, 4).join(', ')}`);
|
|
177
|
+
} else {
|
|
178
|
+
const elsewhere = await searchInstalledSymbols(name, searchDirs, 4);
|
|
179
|
+
const inThat =
|
|
180
|
+
r.status === 'no-symbol' && r.candidates.length
|
|
181
|
+
? ` (closest in that library: ${r.candidates.slice(0, 4).join(', ')})`
|
|
182
|
+
: '';
|
|
183
|
+
const where = elsewhere.length
|
|
184
|
+
? `; installed as: ${elsewhere.join(', ')}`
|
|
185
|
+
: `; no installed symbol matches "${name}" in any library`;
|
|
186
|
+
lines.push(
|
|
187
|
+
r.status === 'no-symbol'
|
|
188
|
+
? `- ${libId}: not in that library${inThat}${where}`
|
|
189
|
+
: `- ${libId}: no library of that nickname is installed${where}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// a single unreadable library must not sink the fact block
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!lines.length) return '';
|
|
197
|
+
if (unprobed.length) {
|
|
198
|
+
lines.push(
|
|
199
|
+
`- NOT RE-PROBED (probe limit ${cap}): ${unprobed.join(', ')} — these were named in the text but not checked, so nothing above says whether they exist.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
119
205
|
/**
|
|
120
206
|
* Ask the model whether a failed/incomplete stage is worth retrying, and if so
|
|
121
207
|
* how. Uses a fresh, tool-less provider turn (the same saved-login backend the
|
|
@@ -132,6 +218,9 @@ export async function diagnoseStageFailure(
|
|
|
132
218
|
excerpt: string;
|
|
133
219
|
attempt: number;
|
|
134
220
|
maxAttempts: number;
|
|
221
|
+
/** Deterministic re-probe results for lib_ids named in the failure/excerpt
|
|
222
|
+
* (`symbolAvailabilityFacts`); authoritative over the transcript's claims. */
|
|
223
|
+
symbolFacts?: string;
|
|
135
224
|
},
|
|
136
225
|
): Promise<StageDiagnosis> {
|
|
137
226
|
const system =
|
|
@@ -145,10 +234,14 @@ export async function diagnoseStageFailure(
|
|
|
145
234
|
`Failure: ${input.failure}\n` +
|
|
146
235
|
`This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
|
|
147
236
|
`Recent transcript (most recent last):\n${input.excerpt}\n\n` +
|
|
237
|
+
(input.symbolFacts
|
|
238
|
+
? `Machine-verified symbol facts — a deterministic re-probe of the lib_ids named above, run just now against this machine's installed KiCad libraries. Each line reported below is ground truth and overrides anything the transcript claims about that symbol's availability. Coverage may be partial: a lib_id listed as NOT RE-PROBED, or absent from this block entirely, is unknown, never confirmed absent.\n${input.symbolFacts}\n\n`
|
|
239
|
+
: '') +
|
|
148
240
|
'Reply with ONLY a JSON object, no prose:\n' +
|
|
149
241
|
'{"verdict":"retry"|"abort","reason":"<one sentence>","guidance":"<if retry: concrete, specific instructions to prepend to the next attempt so it avoids this failure; otherwise empty>"}\n' +
|
|
150
242
|
'- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a timeout, a formatting slip).\n' +
|
|
151
|
-
'- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt)
|
|
243
|
+
'- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).\n' +
|
|
244
|
+
'- an agent\'s claim that a symbol or library is absent is NOT evidence: agents dead-ended by wrong library nicknames routinely conclude whole libraries are missing. If the machine-verified facts contradict the failure\'s premise (a cited-absent lib_id RESOLVES, or the part is installed under another library), the verdict is "retry", with guidance quoting the correct lib_ids.';
|
|
152
245
|
const messages: Msg[] = [
|
|
153
246
|
{ role: 'system', content: system },
|
|
154
247
|
{ role: 'user', content: user },
|
|
@@ -36,13 +36,29 @@ export class CachingProvider implements Provider {
|
|
|
36
36
|
* in the cache key so switching model on the same repo does not replay the
|
|
37
37
|
* other model's cached turns (F6). Falls back to the provider family name. */
|
|
38
38
|
private readonly modelId?: string,
|
|
39
|
+
/** The compat endpoint's base URL, if any. A model id like `compat:llama-3.1-8b-instant`
|
|
40
|
+
* is not unique across hosts (Groq, OpenRouter, etc. all serve overlapping model
|
|
41
|
+
* ids), so the endpoint must be part of the key too, or two different hosts
|
|
42
|
+
* serving "the same" model id would share cached turns. */
|
|
43
|
+
private readonly baseURL?: string,
|
|
39
44
|
) {
|
|
40
45
|
this.name = inner.name;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
private keyFor(messages: Msg[], tools: ToolSchema[]): string {
|
|
44
49
|
return createHash('sha256')
|
|
45
|
-
.update(
|
|
50
|
+
.update(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
model: this.modelId ?? this.name,
|
|
53
|
+
// Omitted rather than `?? null` when unset: a non-compat run's key
|
|
54
|
+
// must stay byte-identical to what it hashed before baseURL existed
|
|
55
|
+
// (F6/D2), or every pre-existing cache entry — not just compat ones —
|
|
56
|
+
// is orphaned on the first run after upgrade.
|
|
57
|
+
...(this.baseURL ? { baseURL: this.baseURL } : {}),
|
|
58
|
+
messages,
|
|
59
|
+
tools: tools.map((t) => t.name),
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
46
62
|
.digest('hex');
|
|
47
63
|
}
|
|
48
64
|
|