copperhead 0.5.0 → 0.7.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.
Files changed (74) hide show
  1. package/README.md +34 -1
  2. package/dist/agent/loop.js +130 -15
  3. package/dist/agent/loop.js.map +1 -1
  4. package/dist/agent/prompts.js +2 -1
  5. package/dist/agent/prompts.js.map +1 -1
  6. package/dist/agent/providers/claude-code.js +466 -0
  7. package/dist/agent/providers/claude-code.js.map +1 -0
  8. package/dist/agent/providers/openai.js +30 -10
  9. package/dist/agent/providers/openai.js.map +1 -1
  10. package/dist/agent/recovery.js +148 -0
  11. package/dist/agent/recovery.js.map +1 -0
  12. package/dist/agent/render.js +17 -2
  13. package/dist/agent/render.js.map +1 -1
  14. package/dist/agent/response-cache.js +81 -0
  15. package/dist/agent/response-cache.js.map +1 -0
  16. package/dist/agent/tools.js +61 -4
  17. package/dist/agent/tools.js.map +1 -1
  18. package/dist/agent/transcript.js.map +1 -1
  19. package/dist/cli.js +47 -2
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/create.js +486 -35
  22. package/dist/commands/create.js.map +1 -1
  23. package/dist/commands/export.js +90 -0
  24. package/dist/commands/export.js.map +1 -0
  25. package/dist/config.js +33 -6
  26. package/dist/config.js.map +1 -1
  27. package/dist/kicad/bom-export.js +240 -0
  28. package/dist/kicad/bom-export.js.map +1 -0
  29. package/dist/kicad/bootstrap.js +166 -0
  30. package/dist/kicad/bootstrap.js.map +1 -0
  31. package/dist/kicad/fab.js +94 -0
  32. package/dist/kicad/fab.js.map +1 -0
  33. package/dist/kicad/spice.js +306 -0
  34. package/dist/kicad/spice.js.map +1 -0
  35. package/dist/kicad/symlib.js +228 -0
  36. package/dist/kicad/symlib.js.map +1 -0
  37. package/dist/memory/bom-table.js +232 -0
  38. package/dist/memory/bom-table.js.map +1 -0
  39. package/dist/memory/drift.js +33 -27
  40. package/dist/memory/drift.js.map +1 -1
  41. package/dist/util/git.js +37 -1
  42. package/dist/util/git.js.map +1 -1
  43. package/dist/util/preflight.js +37 -0
  44. package/dist/util/preflight.js.map +1 -1
  45. package/dist/util/retry.js +23 -0
  46. package/dist/util/retry.js.map +1 -1
  47. package/dist/util/tmp.js +119 -0
  48. package/dist/util/tmp.js.map +1 -0
  49. package/package.json +6 -2
  50. package/src/agent/loop.ts +148 -15
  51. package/src/agent/prompts.ts +2 -1
  52. package/src/agent/providers/claude-code.ts +550 -0
  53. package/src/agent/providers/openai.ts +33 -16
  54. package/src/agent/recovery.ts +162 -0
  55. package/src/agent/render.ts +28 -1
  56. package/src/agent/response-cache.ts +80 -0
  57. package/src/agent/tools.ts +62 -4
  58. package/src/agent/transcript.ts +1 -0
  59. package/src/agent/types.ts +18 -0
  60. package/src/cli.ts +52 -2
  61. package/src/commands/create.ts +543 -38
  62. package/src/commands/export.ts +117 -0
  63. package/src/config.ts +54 -6
  64. package/src/kicad/bom-export.ts +321 -0
  65. package/src/kicad/bootstrap.ts +181 -0
  66. package/src/kicad/fab.ts +121 -0
  67. package/src/kicad/spice.ts +399 -0
  68. package/src/kicad/symlib.ts +248 -0
  69. package/src/memory/bom-table.ts +249 -0
  70. package/src/memory/drift.ts +42 -32
  71. package/src/util/git.ts +37 -1
  72. package/src/util/preflight.ts +44 -0
  73. package/src/util/retry.ts +29 -0
  74. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,550 @@
1
+ import { mkdtemp, rm, utimes } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import type { ChatOpts, Msg, Provider, ToolCall, ToolSchema, Turn } from '../types.js';
5
+
6
+ /**
7
+ * Saved-login provider: drives Claude Code through the Claude Agent SDK
8
+ * (`@anthropic-ai/claude-agent-sdk`) and reuses its saved login (the
9
+ * `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`, or a logged-in CLI), so a
10
+ * Claude subscription user runs copperhead with no ANTHROPIC_API_KEY. What is
11
+ * reused is the login, not necessarily a separately-installed `claude` binary:
12
+ * the SDK ships its own. See the `add-claude-code-provider` change (D1–D6).
13
+ *
14
+ * It is a REASONING-ONLY backend. The Agent SDK is built to run its own
15
+ * autonomous multi-turn tool loop, which conflicts with copperhead's contract
16
+ * that `loop.ts` is the single driver and every mutation flows through the
17
+ * capability-filtered tools, obligations ledger, ERC/DRC gates, snapshot, and
18
+ * commit gate. So each `chat()` issues exactly ONE `query()` with no SDK tools
19
+ * registered and built-ins disabled, in an isolated cwd — the SDK executes
20
+ * nothing. copperhead's tools are advertised to the model as a text protocol;
21
+ * the model replies with a JSON tool-call block that we parse back into
22
+ * `Turn.toolCalls`. The spec-gated-in invariant stays structural: we advertise
23
+ * exactly the tools `availableTools(ctx)` returned for the turn.
24
+ *
25
+ * Auth stays external (D2): the constructor performs no API-key check and the
26
+ * provider never reads, copies, or logs the credential — the SDK resolves
27
+ * CLAUDE_CODE_OAUTH_TOKEN / the logged-in CLI itself.
28
+ */
29
+
30
+ /** Structural subset of the Agent SDK's `query` surface we depend on. Declared
31
+ * locally rather than `import type`d from the SDK on purpose: the SDK is an
32
+ * undeclared optional dependency (design D3), so importing its types would force
33
+ * it to be installed for `tsc` to pass — the exact coupling `codex.ts` now has.
34
+ * Naming the options here still compile-checks our option keys (e.g. `tools`),
35
+ * which is the safety the review asked for without the packaging cost. */
36
+ export type DenyResult = { behavior: 'deny'; message: string; interrupt?: boolean };
37
+ export type AllowResult = { behavior: 'allow'; updatedInput?: Record<string, unknown> };
38
+ export type CanUseToolLike = (
39
+ toolName: string,
40
+ input: Record<string, unknown>,
41
+ ) => Promise<DenyResult | AllowResult>;
42
+
43
+ export interface QueryOptions {
44
+ systemPrompt?: string;
45
+ model?: string;
46
+ /** `[]` disables all built-in tools (Agent SDK 0.3.x). */
47
+ tools?: string[];
48
+ disallowedTools?: string[];
49
+ /** Called before any tool executes; we deny everything (reasoning-only). */
50
+ canUseTool?: CanUseToolLike;
51
+ cwd?: string;
52
+ env?: Record<string, string | undefined>;
53
+ maxTurns?: number;
54
+ /** Aborting this controller stops the query and tears down the `claude`
55
+ * subprocess it spawned (Agent SDK `Options.abortController`). Used so the
56
+ * watchdog's `close()` on a hung turn kills the process instead of orphaning
57
+ * it (2.2/4.1) — a stranded subprocess keeps writing to its temp cwd and, with
58
+ * KiCad local history, was a source of the disk-fill halt (I8). */
59
+ abortController?: AbortController;
60
+ /** Resume a prior SDK session by id so the subprocess reconstructs earlier
61
+ * turns itself instead of us re-sending the whole conversation each turn (1.1,
62
+ * `Options.resume`). Only set in the opt-in session-resume mode. */
63
+ resume?: string;
64
+ }
65
+ export interface QueryArgs {
66
+ prompt: string;
67
+ options?: QueryOptions;
68
+ }
69
+ export interface QueryMessage {
70
+ type: string;
71
+ subtype?: string;
72
+ session_id?: string;
73
+ message?: { content?: Array<{ type: string; text?: string }> };
74
+ usage?: { input_tokens?: number; output_tokens?: number };
75
+ }
76
+ export type QueryLike = (args: QueryArgs) => AsyncIterable<QueryMessage>;
77
+
78
+ /** Injectable dynamic import of the optional SDK. Exists so the
79
+ * missing-dependency path stays testable now that the SDK is a declared
80
+ * optional dependency (installed in a normal/CI install). The default keeps the
81
+ * specifier non-literal so tsc never resolves the optional dep at build time. */
82
+ export type ImportLike = (specifier: string) => Promise<unknown>;
83
+
84
+ /** Explicit deny list, belt-and-suspenders on top of the empty `tools` allowlist.
85
+ * `'*'` is the documented wildcard; the named entries stay for clarity and in
86
+ * case a given SDK build does not honor the wildcard. */
87
+ const DISALLOWED_BUILTINS = [
88
+ '*',
89
+ 'Bash',
90
+ 'Edit',
91
+ 'MultiEdit',
92
+ 'Write',
93
+ 'Read',
94
+ 'Glob',
95
+ 'Grep',
96
+ 'NotebookEdit',
97
+ 'WebFetch',
98
+ 'WebSearch',
99
+ 'Task',
100
+ 'TodoWrite',
101
+ ];
102
+
103
+ export class ClaudeCodeProvider implements Provider {
104
+ readonly name = 'claude-code';
105
+ private callSeq = 0;
106
+ private cwdPromise?: Promise<string>;
107
+ /** In-flight query aborters, so close() (called by the turn watchdog on a
108
+ * hung turn) can tear down the live subprocess, not just delete its cwd. */
109
+ private readonly inFlight = new Set<AbortController>();
110
+ /** Session-resume state (1.1). `sessionId` is the last session the SDK reported;
111
+ * `sentCount` is how many `messages` we have already handed it, so a resumed
112
+ * turn sends only the delta. Unused unless `sessionResume` is on. */
113
+ private sessionId?: string;
114
+ private sentCount = 0;
115
+
116
+ constructor(
117
+ private readonly model?: string,
118
+ private readonly injectedQuery?: QueryLike,
119
+ private readonly importSdk: ImportLike = (specifier) => import(specifier),
120
+ /**
121
+ * Opt-in: resume one SDK session across turns and send only new messages,
122
+ * instead of flattening and re-sending the entire conversation every turn
123
+ * (1.1). Cuts the ~quadratic history re-send that dominates long-stage cost.
124
+ * OFF by default and deliberately mutually exclusive with the response cache:
125
+ * the cache replays turns the resumed session never saw, so mixing them would
126
+ * desync the session. `makeProvider` enables it only when the cache is off.
127
+ */
128
+ private readonly sessionResume = false,
129
+ ) {}
130
+
131
+ // `opts.maxTokens` is intentionally ignored: the Agent SDK drives the Claude
132
+ // Code subprocess and exposes no per-call max-tokens knob. `opts.onStream` is
133
+ // honored: this provider streams, so it reports cumulative streamed-text length
134
+ // as blocks arrive, which the loop turns into a liveness heartbeat (5.1).
135
+ async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
136
+ const query = await this.resolveQuery();
137
+
138
+ const system = messages
139
+ .filter((m) => m.role === 'system')
140
+ .map((m) => m.content)
141
+ .join('\n\n');
142
+ const systemPrompt = [system, renderToolProtocol(tools)].filter(Boolean).join('\n\n');
143
+ // Session-resume mode (1.1): once the SDK has given us a session id, resume it
144
+ // and send only the messages added since our last turn — the subprocess still
145
+ // holds the earlier conversation, so re-sending it would just re-bill it. The
146
+ // first turn (no session id yet) sends the full flattened history as usual.
147
+ const resume = this.sessionResume ? this.sessionId : undefined;
148
+ const prompt = resume ? renderDelta(messages, this.sentCount) : renderConversation(messages);
149
+ const catalog = new Set(tools.map((t) => t.name));
150
+ const cwd = await this.ensureCwd();
151
+
152
+ let text: string | null = null;
153
+ let inputTokens = 0;
154
+ let outputTokens = 0;
155
+ // One aborter per turn: close() aborts it to kill a hung subprocess.
156
+ const aborter = new AbortController();
157
+ this.inFlight.add(aborter);
158
+ try {
159
+ for await (const msg of query({
160
+ prompt,
161
+ options: {
162
+ systemPrompt,
163
+ ...(this.model ? { model: this.model } : {}),
164
+ abortController: aborter,
165
+ // Layered "the SDK executes nothing" defense (D1/D5):
166
+ // 1. `tools: []` disables ALL built-in tools (Agent SDK 0.3.x docs:
167
+ // "[] (empty array) - Disable all built-in tools").
168
+ // 2. `disallowedTools` denies by name, with a wildcard, as a backstop.
169
+ // 3. `canUseTool` denies every tool BEFORE it runs — the permission
170
+ // analog to Codex's read-only sandbox — so even an unrecognized
171
+ // future tool cannot execute.
172
+ // 4. The tool_use tripwire below fails the run loudly if one is
173
+ // emitted anyway. Any single layer failing is caught by the next.
174
+ tools: [],
175
+ ...(resume ? { resume } : {}),
176
+ disallowedTools: DISALLOWED_BUILTINS,
177
+ canUseTool: async (toolName) => ({
178
+ behavior: 'deny',
179
+ message: `copperhead claude-code is reasoning-only; the SDK must not execute tools (blocked ${toolName}).`,
180
+ interrupt: true,
181
+ }),
182
+ cwd,
183
+ // The SDK's `env` REPLACES the subprocess environment entirely, so
184
+ // inherit process.env and strip the billed API keys: a claude-code run
185
+ // must use the saved login and never silently a paid ANTHROPIC_API_KEY
186
+ // / OPENAI_API_KEY, even when one is also set (D2).
187
+ env: { ...process.env, ANTHROPIC_API_KEY: undefined, OPENAI_API_KEY: undefined },
188
+ maxTurns: 1,
189
+ },
190
+ })) {
191
+ if (msg.type === 'assistant') {
192
+ for (const block of msg.message?.content ?? []) {
193
+ if (block.type === 'text' && block.text) {
194
+ text = (text ?? '') + block.text;
195
+ // Report progress so the loop's heartbeat shows this turn is alive
196
+ // and streaming, not hung, during a multi-minute large-output turn.
197
+ opts.onStream?.(text.length);
198
+ } else if (block.type === 'tool_use') {
199
+ // Load-bearing invariant (D1): the SDK must execute nothing, so it
200
+ // must never emit a tool_use block. If it does, `tools: []` was not
201
+ // honored — fail loudly rather than let an edit bypass copperhead's
202
+ // snapshot / verify / commit gates.
203
+ throw new Error(
204
+ 'claude-code: the Agent SDK emitted a tool_use block, but its tools are ' +
205
+ 'disabled — the reasoning-only invariant was violated (SDK option drift?). ' +
206
+ 'Refusing to continue.',
207
+ );
208
+ }
209
+ }
210
+ } else if (msg.type === 'result') {
211
+ if (typeof msg.usage?.input_tokens === 'number') inputTokens = msg.usage.input_tokens;
212
+ if (typeof msg.usage?.output_tokens === 'number') outputTokens = msg.usage.output_tokens;
213
+ }
214
+ // The session id can arrive on any message (init/system/result); keep the
215
+ // latest so the next turn can resume it (1.1). No-op unless resume is on.
216
+ if (this.sessionResume && typeof msg.session_id === 'string') this.sessionId = msg.session_id;
217
+ }
218
+ } catch (err) {
219
+ // Auth failures get an actionable message (non-retryable); everything else
220
+ // — crucially a 429 — is re-thrown untouched so its status survives for
221
+ // withRetry/isRateLimit (D4). We never fall back to a keyed provider: the
222
+ // distinct `name` makes otherProvider() return null for us.
223
+ if (isAuthError(err)) throw new Error(authHint((err as Error).message));
224
+ throw err;
225
+ } finally {
226
+ this.inFlight.delete(aborter);
227
+ }
228
+
229
+ // Only advance the high-water mark on a turn that completed: a thrown turn
230
+ // (rate limit, timeout) is retried, and must re-send the same delta so no
231
+ // message is lost from the resumed session (1.1).
232
+ if (this.sessionResume) this.sentCount = messages.length;
233
+
234
+ const parsed = parseToolCalls(text, () => `cc-${++this.callSeq}`, catalog);
235
+ return {
236
+ text: parsed.text,
237
+ toolCalls: parsed.toolCalls,
238
+ usage: { inputTokens, outputTokens },
239
+ nudge: parsed.nudge,
240
+ };
241
+ }
242
+
243
+ /** Tear down in-flight work and remove the scratch cwd. Called by the turn
244
+ * watchdog on a hung turn (via withTimeout's onTimeout) AND once per run in a
245
+ * finally. Aborting first kills the `claude` subprocess a hung turn spawned —
246
+ * without it the process is orphaned and keeps writing to its temp cwd, which
247
+ * (with KiCad local history) was a source of the disk-fill halt (2.2/4.1, I8).
248
+ * A leftover empty dir in the OS tmpdir is harmless; the startup sweep reclaims
249
+ * any that a hard SIGKILL bypassed this cleanup for. */
250
+ async close(): Promise<void> {
251
+ for (const aborter of this.inFlight) {
252
+ try {
253
+ aborter.abort();
254
+ } catch {
255
+ // best effort: a controller that already settled throws nothing useful
256
+ }
257
+ }
258
+ this.inFlight.clear();
259
+ const pending = this.cwdPromise;
260
+ this.cwdPromise = undefined;
261
+ if (!pending) return;
262
+ try {
263
+ await rm(await pending, { recursive: true, force: true });
264
+ } catch {
265
+ // best effort: a leftover empty dir in the OS tmpdir is harmless
266
+ }
267
+ }
268
+
269
+ /** One isolated scratch cwd per provider instance, created once and reused
270
+ * across turns so a long run does not leak a temp dir per turn. Even with
271
+ * tools disabled this guarantees the SDK has no path into the repo (D5). */
272
+ private async ensureCwd(): Promise<string> {
273
+ if (!this.cwdPromise) this.cwdPromise = mkdtemp(path.join(os.tmpdir(), 'copperhead-cc-'));
274
+ const cwd = await this.cwdPromise;
275
+ // Keep this reused scratch dir's mtime fresh on every turn. It is the only
276
+ // long-lived temp dir a run holds (kicad-cli dirs are per-call), so a
277
+ // multi-hour run would otherwise leave it with a stale mtime and a concurrent
278
+ // run's startup sweep (sweepStaleTempDirs, age-gated) could delete it out from
279
+ // under the live process (F4). Best-effort: a touch failure is harmless.
280
+ const now = new Date();
281
+ await utimes(cwd, now, now).catch(() => {});
282
+ return cwd;
283
+ }
284
+
285
+ private async resolveQuery(): Promise<QueryLike> {
286
+ if (this.injectedQuery) return this.injectedQuery;
287
+ let mod: { query?: QueryLike; default?: { query?: QueryLike } };
288
+ try {
289
+ // importSdk defaults to a non-literal `import()`, so tsc never resolves the
290
+ // optional dependency at build time and it may legitimately be absent (D3).
291
+ mod = (await this.importSdk('@anthropic-ai/claude-agent-sdk')) as {
292
+ query?: QueryLike;
293
+ default?: { query?: QueryLike };
294
+ };
295
+ } catch (err) {
296
+ // Only a genuinely-absent module gets the "install it" message; a present
297
+ // but broken install surfaces its real error rather than being mislabeled.
298
+ const code = (err as { code?: string }).code;
299
+ if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
300
+ throw new Error(
301
+ 'the claude-code provider needs the optional dependency @anthropic-ai/claude-agent-sdk; ' +
302
+ 'install it with `npm i @anthropic-ai/claude-agent-sdk`',
303
+ );
304
+ }
305
+ throw err;
306
+ }
307
+ const query = mod.query ?? mod.default?.query;
308
+ if (!query) {
309
+ throw new Error(
310
+ '@anthropic-ai/claude-agent-sdk did not export `query`; the installed version may be incompatible',
311
+ );
312
+ }
313
+ return query;
314
+ }
315
+ }
316
+
317
+ function renderToolProtocol(tools: ToolSchema[]): string {
318
+ if (!tools.length) return '';
319
+ const lines = [
320
+ '# Tool protocol',
321
+ '',
322
+ 'You are the reasoning half of a tool-driven workflow; you cannot run anything yourself.',
323
+ 'To take an action, reply with EXACTLY ONE JSON object and nothing else, wrapped in a',
324
+ '```json fenced code block:',
325
+ '',
326
+ '```json',
327
+ '{"tool": "<tool_name>", "args": { ... }}',
328
+ '```',
329
+ '',
330
+ 'Use only the tools listed below, with `args` matching the tool\'s JSON Schema. If you have',
331
+ 'no tool to call and only want to say something, reply with plain prose and no JSON block.',
332
+ '',
333
+ '## Available tools',
334
+ ];
335
+ for (const t of tools) {
336
+ lines.push(
337
+ '',
338
+ `### ${t.name}`,
339
+ t.description,
340
+ `Parameters (JSON Schema): ${JSON.stringify(t.parameters)}`,
341
+ );
342
+ }
343
+ return lines.join('\n');
344
+ }
345
+
346
+ /**
347
+ * The prompt for a *resumed* turn (1.1): only the messages added since the last
348
+ * turn we sent, and only the ones the resumed session does not already hold. The
349
+ * subprocess already has every prior turn plus its own assistant replies, so we
350
+ * send just the new user nudges and tool results — that delta is what advances
351
+ * the conversation. Falls back to the full render (via the caller) when there is
352
+ * no session yet.
353
+ */
354
+ function renderDelta(messages: Msg[], from: number): string {
355
+ const idToName = new Map<string, string>();
356
+ for (const m of messages) {
357
+ if (m.role === 'assistant') for (const call of m.toolCalls ?? []) idToName.set(call.id, call.name);
358
+ }
359
+ const parts: string[] = [];
360
+ for (const m of messages.slice(Math.max(0, from))) {
361
+ if (m.role === 'user') {
362
+ parts.push(`[user]\n${m.content}`);
363
+ } else if (m.role === 'tool') {
364
+ const name = idToName.get(m.toolCallId) ?? m.toolCallId;
365
+ parts.push(`[result of ${name}]\n${m.content}`);
366
+ }
367
+ // assistant/system messages are already in the resumed session — skip them.
368
+ }
369
+ return parts.join('\n\n');
370
+ }
371
+
372
+ function renderConversation(messages: Msg[]): string {
373
+ const idToName = new Map<string, string>();
374
+ const parts: string[] = [];
375
+ for (const m of messages) {
376
+ if (m.role === 'system') continue;
377
+ if (m.role === 'user') {
378
+ parts.push(`[user]\n${m.content}`);
379
+ } else if (m.role === 'assistant') {
380
+ if (m.content) parts.push(`[assistant]\n${m.content}`);
381
+ for (const call of m.toolCalls ?? []) {
382
+ idToName.set(call.id, call.name);
383
+ parts.push(
384
+ `[assistant tool call]\n\`\`\`json\n${JSON.stringify({ tool: call.name, args: call.args })}\n\`\`\``,
385
+ );
386
+ }
387
+ } else {
388
+ const name = idToName.get(m.toolCallId) ?? m.toolCallId;
389
+ parts.push(`[result of ${name}]\n${m.content}`);
390
+ }
391
+ }
392
+ return parts.join('\n\n');
393
+ }
394
+
395
+ interface Parsed {
396
+ text: string | null;
397
+ toolCalls: ToolCall[];
398
+ nudge?: string;
399
+ }
400
+
401
+ /**
402
+ * Detect a malformed-but-intended tool call in a turn that dispatched none
403
+ * (#I10). The signature is machine-recognizable: the text contains
404
+ * `"tool":"<name>"` naming a tool in the current catalog, yet nothing parsed.
405
+ * That is the exact case where the tolerant extractor's silence misleads the
406
+ * model — the JSON was near-miss malformed (a brace short, or the outer object
407
+ * split so only an inner `{args}` with no `tool` key balanced), not the tool
408
+ * being broken. Returns a one-line steer to re-emit it, or undefined when the
409
+ * absence of a call is genuine (plain prose, no tool named).
410
+ */
411
+ function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
412
+ const re = /"tool"\s*:\s*"([^"]+)"/g;
413
+ let m: RegExpExecArray | null;
414
+ while ((m = re.exec(text)) !== null) {
415
+ const name = m[1]!;
416
+ if (catalog.has(name)) {
417
+ return (
418
+ `A tool call for "${name}" looks malformed — it named the tool but did not parse as ` +
419
+ 'valid JSON (likely unbalanced braces or a missing closing brace), so no call ran. ' +
420
+ 'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
421
+ );
422
+ }
423
+ }
424
+ return undefined;
425
+ }
426
+
427
+ /**
428
+ * Extract tool-call JSON from the model's reply. Tolerant by design (D1):
429
+ * unparseable output is returned as plain text with no tool calls rather than
430
+ * throwing, so a non-conforming turn degrades to the loop's stall/nudge path.
431
+ * A parsed block only counts as a tool call when its name is in the current
432
+ * turn's catalog (`availableTools(ctx)`): a hallucinated or locked tool name is
433
+ * left as prose so the loop nudges, rather than dispatching a bogus call.
434
+ */
435
+ function parseToolCalls(text: string | null, nextId: () => string, catalog: Set<string>): Parsed {
436
+ if (!text) return { text: null, toolCalls: [] };
437
+ const toolCalls: ToolCall[] = [];
438
+ const matched: Array<[number, number]> = [];
439
+
440
+ // Extract tool calls by scanning for complete JSON objects, NOT by matching
441
+ // ``` fences. A tool call's `content`/`args` can hold a full markdown doc that
442
+ // itself contains ``` code fences; a fence regex truncates the JSON at the
443
+ // first inner fence, JSON.parse fails, and the call is silently dropped (the
444
+ // model then assumes it wrote a file it never did). The brace scan is
445
+ // string-aware, so braces and backticks inside JSON string values are ignored.
446
+ let searchFrom = 0;
447
+ while (searchFrom < text.length) {
448
+ const braceAt = text.indexOf('{', searchFrom);
449
+ if (braceAt < 0) break;
450
+ const span = scanJsonObject(text, braceAt);
451
+ if (!span) {
452
+ // Unbalanced '{' (stray brace in prose): retry from the next candidate so
453
+ // one bad brace can't hide a well-formed call later in the reply.
454
+ searchFrom = braceAt + 1;
455
+ continue;
456
+ }
457
+ const call = toToolCall(text.slice(span.start, span.end), nextId, catalog);
458
+ if (call) {
459
+ toolCalls.push(call);
460
+ matched.push([span.start, span.end]);
461
+ }
462
+ searchFrom = span.end;
463
+ }
464
+
465
+ if (!toolCalls.length) {
466
+ // No call dispatched — but did the model clearly *intend* one? A fenced
467
+ // ```json block that names a catalog tool yet produced zero calls is a
468
+ // malformed near-miss (unbalanced braces, a missing `}`, or an inner object
469
+ // with no `tool` key). Silently dropping it gives the model no signal, so it
470
+ // misreads "no result" as "this tool is broken" and can bake that false
471
+ // conclusion into a committed summary (#I10). Surface a nudge instead.
472
+ return { text: text.trim() ? text : null, toolCalls, nudge: detectMalformedCall(text, catalog) };
473
+ }
474
+
475
+ // Prose is whatever survives once the tool-call objects (and any now-empty
476
+ // ```json fences around them) are removed.
477
+ let prose = '';
478
+ let cursor = 0;
479
+ for (const [start, end] of matched) {
480
+ prose += text.slice(cursor, start);
481
+ cursor = end;
482
+ }
483
+ prose += text.slice(cursor);
484
+ prose = prose.replace(/```(?:json)?\s*```/gi, '').replace(/```(?:json)?\s*$/gi, '').trim();
485
+ return { text: prose.length ? prose : null, toolCalls };
486
+ }
487
+
488
+ /**
489
+ * Find the first complete, brace-balanced JSON object at or after `from`,
490
+ * respecting JSON string quoting/escaping so braces or backticks inside string
491
+ * values do not end the scan. Returns its `[start, end)` bounds or null.
492
+ */
493
+ function scanJsonObject(text: string, from: number): { start: number; end: number } | null {
494
+ const start = text.indexOf('{', from);
495
+ if (start < 0) return null;
496
+ let depth = 0;
497
+ let inStr = false;
498
+ let esc = false;
499
+ for (let i = start; i < text.length; i++) {
500
+ const ch = text[i];
501
+ if (inStr) {
502
+ if (esc) esc = false;
503
+ else if (ch === '\\') esc = true;
504
+ else if (ch === '"') inStr = false;
505
+ continue;
506
+ }
507
+ if (ch === '"') inStr = true;
508
+ else if (ch === '{') depth++;
509
+ else if (ch === '}' && --depth === 0) return { start, end: i + 1 };
510
+ }
511
+ return null;
512
+ }
513
+
514
+ function toToolCall(raw: string | undefined, nextId: () => string, catalog: Set<string>): ToolCall | null {
515
+ if (!raw) return null;
516
+ let obj: unknown;
517
+ try {
518
+ obj = JSON.parse(raw.trim());
519
+ } catch {
520
+ return null;
521
+ }
522
+ if (!obj || typeof obj !== 'object') return null;
523
+ const rec = obj as Record<string, unknown>;
524
+ if (typeof rec.tool !== 'string') return null;
525
+ // Only accept names the turn actually advertised. An empty catalog means the
526
+ // turn offered no tools, so nothing parses as a call.
527
+ if (!catalog.has(rec.tool)) return null;
528
+ const args = rec.args && typeof rec.args === 'object' ? (rec.args as Record<string, unknown>) : {};
529
+ return { id: nextId(), name: rec.tool, args };
530
+ }
531
+
532
+ function isAuthError(err: unknown): boolean {
533
+ const status = (err as { status?: number; statusCode?: number })?.status
534
+ ?? (err as { statusCode?: number })?.statusCode;
535
+ // A present status is authoritative: only 401/403 are auth failures, so a 429
536
+ // (or anything else) is NOT treated as auth and is re-thrown untouched — its
537
+ // status must survive for withRetry/isRateLimit even if the message mentions
538
+ // "oauth token". Only when there is no status do we fall back to a narrow
539
+ // message heuristic.
540
+ if (typeof status === 'number') return status === 401 || status === 403;
541
+ const m = ((err as Error)?.message ?? '').toLowerCase();
542
+ return /unauthenticat|unauthoriz|not logged in|please log in|invalid api key|oauth token|setup-token/.test(m);
543
+ }
544
+
545
+ function authHint(detail: string): string {
546
+ return (
547
+ 'claude-code is not authenticated: log in to Claude Code, or run `claude setup-token` and ' +
548
+ `set CLAUDE_CODE_OAUTH_TOKEN. copperhead never reads your credential itself (original error: ${detail})`
549
+ );
550
+ }
@@ -1,9 +1,4 @@
1
- import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
2
-
3
- interface OpenAIToolCall {
4
- id: string;
5
- function: { name: string; arguments: string };
6
- }
1
+ import type { ChatOpts, Msg, Provider, ToolSchema, Turn, ToolCall } from '../types.js';
7
2
 
8
3
  export class OpenAIProvider implements Provider {
9
4
  readonly name = 'openai';
@@ -33,11 +28,7 @@ export class OpenAIProvider implements Provider {
33
28
  content: m.content,
34
29
  ...(m.toolCalls?.length
35
30
  ? {
36
- tool_calls: m.toolCalls.map((t) => ({
37
- id: t.id,
38
- type: 'function' as const,
39
- function: { name: t.name, arguments: JSON.stringify(t.args) },
40
- })),
31
+ tool_calls: m.toolCalls.map(serializeToolCall),
41
32
  }
42
33
  : {}),
43
34
  };
@@ -55,11 +46,10 @@ export class OpenAIProvider implements Provider {
55
46
  : {}),
56
47
  });
57
48
  const choice = res.choices[0];
58
- const toolCalls = ((choice?.message.tool_calls ?? []) as OpenAIToolCall[]).map((t) => ({
59
- id: t.id,
60
- name: t.function.name,
61
- args: safeParse(t.function.arguments),
62
- }));
49
+ // Capture any non-standard properties returned by the API (e.g. Gemini thought
50
+ // signatures) so they can be echoed back on subsequent turns. Dropping them
51
+ // causes reasoning-model backends to reject the follow-up request with 400.
52
+ const toolCalls = ((choice?.message.tool_calls ?? []) as unknown as Record<string, unknown>[]).map(parseToolCall);
63
53
  return {
64
54
  text: choice?.message.content ?? null,
65
55
  toolCalls,
@@ -78,3 +68,30 @@ function safeParse(s: string): Record<string, unknown> {
78
68
  return { _raw: s };
79
69
  }
80
70
  }
71
+
72
+ export function serializeToolCall(t: ToolCall) {
73
+ return {
74
+ id: t.id,
75
+ type: 'function' as const,
76
+ function: { name: t.name, arguments: JSON.stringify(t.args) },
77
+ // Preserve vendor-specific tool-call fields (e.g. Gemini thought signatures).
78
+ // Dropping them makes the next turn's request 400.
79
+ ...(t.extra || {}),
80
+ };
81
+ }
82
+
83
+ export function parseToolCall(t: Record<string, unknown>): ToolCall {
84
+ const extra: Record<string, unknown> = {};
85
+ for (const [k, v] of Object.entries(t)) {
86
+ if (k !== 'id' && k !== 'type' && k !== 'function') {
87
+ extra[k] = v;
88
+ }
89
+ }
90
+ const fn = t.function as { name: string; arguments: string };
91
+ return {
92
+ id: t.id as string,
93
+ name: fn.name,
94
+ args: safeParse(fn.arguments),
95
+ ...(Object.keys(extra).length ? { extra } : {}),
96
+ };
97
+ }