copperhead 0.10.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/README.md +41 -2
- 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/loop.js +34 -14
- package/dist/agent/loop.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/recovery.js +91 -14
- 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 +99 -769
- package/dist/agent/tools.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 +84 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +5 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +33 -3
- 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 +18 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/draft/draft.js +3 -0
- package/dist/kicad/draft/draft.js.map +1 -1
- package/dist/kicad/draft/engine.js +3139 -218
- package/dist/kicad/draft/engine.js.map +1 -1
- package/dist/kicad/draft/symsource.js +24 -10
- package/dist/kicad/draft/symsource.js.map +1 -1
- package/dist/kicad/emit.js +45 -6
- package/dist/kicad/emit.js.map +1 -1
- package/dist/kicad/legibility.js +51 -4
- package/dist/kicad/legibility.js.map +1 -1
- package/dist/kicad/score.js +173 -3
- package/dist/kicad/score.js.map +1 -1
- package/dist/kicad/sexp.js +32 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/scaffold.js +8 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/package.json +5 -2
- 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/loop.ts +45 -17
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/recovery.ts +89 -12
- 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 -816
- 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 +84 -1
- package/src/commands/create.ts +5 -2
- package/src/commands/doctor.ts +34 -3
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +32 -8
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/draft/draft.ts +2 -0
- package/src/kicad/draft/engine.ts +3034 -226
- package/src/kicad/draft/symsource.ts +24 -10
- package/src/kicad/emit.ts +71 -7
- package/src/kicad/legibility.ts +55 -6
- package/src/kicad/score.ts +187 -8
- package/src/kicad/sexp.ts +37 -6
- package/src/mcp/server.ts +560 -0
- package/src/memory/scaffold.ts +8 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { redactSecrets } from '../util/redact.js';
|
|
2
|
+
|
|
3
|
+
/** Envelope protocol version for the ToolResult shape (design D5). */
|
|
4
|
+
export const PROTOCOL_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export type ViewHint = 'diagnostic' | 'mutation' | 'query' | 'export';
|
|
7
|
+
export type ToolErrorKind = 'unavailable' | 'validation' | 'refusal' | 'exception';
|
|
8
|
+
|
|
9
|
+
export interface ToolResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
summary: string;
|
|
12
|
+
detail?: string;
|
|
13
|
+
data?: unknown;
|
|
14
|
+
error?: { kind: ToolErrorKind; message: string };
|
|
15
|
+
viewHint?: ViewHint;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function redactEnvelopeText(text: string): string {
|
|
19
|
+
let out = redactSecrets(text);
|
|
20
|
+
for (const [name, val] of Object.entries(process.env)) {
|
|
21
|
+
if (!val || val.length < 8) continue;
|
|
22
|
+
if (!/_(KEY|SECRET|TOKEN)$/.test(name)) continue;
|
|
23
|
+
if (out.includes(val)) out = out.split(val).join('[REDACTED]');
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function redactDeep(value: unknown): unknown {
|
|
29
|
+
if (typeof value === 'string') return redactEnvelopeText(value);
|
|
30
|
+
if (Array.isArray(value)) return value.map(redactDeep);
|
|
31
|
+
if (value && typeof value === 'object') {
|
|
32
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redactDeep(v)]));
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Redact every string field at construction so render/dock never see secrets. */
|
|
38
|
+
export function seal(partial: ToolResult): ToolResult {
|
|
39
|
+
return {
|
|
40
|
+
ok: partial.ok,
|
|
41
|
+
summary: redactEnvelopeText(partial.summary),
|
|
42
|
+
...(partial.detail !== undefined ? { detail: redactEnvelopeText(partial.detail) } : {}),
|
|
43
|
+
...(partial.data !== undefined ? { data: redactDeep(partial.data) } : {}),
|
|
44
|
+
...(partial.error
|
|
45
|
+
? { error: { kind: partial.error.kind, message: redactEnvelopeText(partial.error.message) } }
|
|
46
|
+
: {}),
|
|
47
|
+
...(partial.viewHint ? { viewHint: partial.viewHint } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function okResult(text: string, viewHint?: ViewHint, data?: unknown): ToolResult {
|
|
52
|
+
const first = text.split('\n')[0] ?? text;
|
|
53
|
+
return seal({
|
|
54
|
+
ok: true,
|
|
55
|
+
summary: first,
|
|
56
|
+
...(text !== first ? { detail: text } : {}),
|
|
57
|
+
...(data !== undefined ? { data } : {}),
|
|
58
|
+
...(viewHint ? { viewHint } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function failResult(kind: ToolErrorKind, message: string, viewHint?: ViewHint): ToolResult {
|
|
63
|
+
const first = message.split('\n')[0] ?? message;
|
|
64
|
+
return seal({
|
|
65
|
+
ok: false,
|
|
66
|
+
summary: first,
|
|
67
|
+
error: { kind, message },
|
|
68
|
+
...(viewHint ? { viewHint } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Flatten an envelope to the string providers already consume as tool Msg content. */
|
|
73
|
+
export function flatten(result: ToolResult): string {
|
|
74
|
+
if (result.error) return result.error.message;
|
|
75
|
+
if (result.detail) {
|
|
76
|
+
return result.detail.startsWith(result.summary) ? result.detail : `${result.summary}\n${result.detail}`;
|
|
77
|
+
}
|
|
78
|
+
return result.summary;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build an envelope from text plus an outcome already known by the handler. */
|
|
82
|
+
export function outcomeResult(text: string, ok: boolean, viewHint?: ViewHint): ToolResult {
|
|
83
|
+
const first = text.split('\n')[0] ?? text;
|
|
84
|
+
return seal({
|
|
85
|
+
ok,
|
|
86
|
+
summary: first,
|
|
87
|
+
...(text !== first ? { detail: text } : {}),
|
|
88
|
+
...(viewHint ? { viewHint } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wrap a legacy handler string. Error-shaped prefixes become typed failures so
|
|
94
|
+
* validation/refusal are not retried; everything else is success. Flattened
|
|
95
|
+
* text equals the original string.
|
|
96
|
+
*/
|
|
97
|
+
export function textResult(text: string, viewHint: ViewHint): ToolResult {
|
|
98
|
+
if (
|
|
99
|
+
/^error:/.test(text) ||
|
|
100
|
+
text.startsWith('rejected:') ||
|
|
101
|
+
text.startsWith('edit REVERTED') ||
|
|
102
|
+
text.startsWith('validation FAILED')
|
|
103
|
+
) {
|
|
104
|
+
return failResult('validation', text, viewHint);
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
text.startsWith('refused:') ||
|
|
108
|
+
text.startsWith('cannot finish yet') ||
|
|
109
|
+
text.startsWith('proposal validated but human declined')
|
|
110
|
+
) {
|
|
111
|
+
return failResult('refusal', text, viewHint);
|
|
112
|
+
}
|
|
113
|
+
return okResult(text, viewHint);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function unavailable(name: string, editsUnlocked: boolean, reason?: string): ToolResult {
|
|
117
|
+
const suffix = reason
|
|
118
|
+
? ` (${reason})`
|
|
119
|
+
: editsUnlocked
|
|
120
|
+
? ''
|
|
121
|
+
: ' (edit tools unlock after the proposal validates)';
|
|
122
|
+
const message = `tool "${name}" is not available${suffix}`;
|
|
123
|
+
return failResult('unavailable', message);
|
|
124
|
+
}
|
package/src/agent/loop.ts
CHANGED
|
@@ -2,9 +2,10 @@ 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';
|
|
10
11
|
import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
|
|
@@ -349,7 +350,7 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
|
|
|
349
350
|
let plan: string | null = null;
|
|
350
351
|
let nudges = 0;
|
|
351
352
|
let turnTimeouts = 0;
|
|
352
|
-
const maxTurnTimeouts =
|
|
353
|
+
const maxTurnTimeouts = MAX_TURN_TIMEOUTS;
|
|
353
354
|
|
|
354
355
|
const stats = (exitPath: ExitPath): RunStats => ({
|
|
355
356
|
exitPath,
|
|
@@ -500,28 +501,54 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
|
|
|
500
501
|
: null;
|
|
501
502
|
heartbeat?.unref?.();
|
|
502
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.
|
|
503
508
|
res = await withRetry(
|
|
504
509
|
() =>
|
|
505
|
-
|
|
506
|
-
() =>
|
|
507
|
-
|
|
508
|
-
|
|
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?.() },
|
|
509
519
|
),
|
|
510
520
|
{ onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
|
|
511
521
|
);
|
|
512
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
|
+
}
|
|
513
535
|
if (err instanceof TurnTimeoutError) {
|
|
514
|
-
// A hung provider turn:
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
// 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.
|
|
518
540
|
if (turnTimeouts++ < maxTurnTimeouts) {
|
|
519
|
-
log(
|
|
520
|
-
|
|
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 });
|
|
521
545
|
turn--;
|
|
522
546
|
continue;
|
|
523
547
|
}
|
|
524
|
-
return fail(
|
|
548
|
+
return fail(
|
|
549
|
+
`provider turns timed out ${turnTimeouts}× (no response or progress for ${fmtDuration(err.ms)} each)`,
|
|
550
|
+
'provider-error',
|
|
551
|
+
);
|
|
525
552
|
}
|
|
526
553
|
if (isRateLimit(err)) {
|
|
527
554
|
const fallback = otherProvider(provider);
|
|
@@ -590,9 +617,10 @@ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Pro
|
|
|
590
617
|
nudges = 0;
|
|
591
618
|
|
|
592
619
|
for (const call of res.toolCalls) {
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
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);
|
|
596
624
|
messages.push({ role: 'tool', toolCallId: call.id, content: result });
|
|
597
625
|
}
|
|
598
626
|
|
|
@@ -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)
|
package/src/agent/recovery.ts
CHANGED
|
@@ -4,10 +4,23 @@ import path from 'node:path';
|
|
|
4
4
|
import type { Msg, Provider } from './types.js';
|
|
5
5
|
import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* How many times a hung provider turn is retried before the run gives up. One
|
|
9
|
+
* policy, two turn loops: the main agent loop and the nested skill sub-run.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_TURN_TIMEOUTS = 3;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thrown when a provider turn trips its watchdog. `idle`: no response or
|
|
15
|
+
* progress for `ms`, so the call looks hung. `max`: still running at the hard
|
|
16
|
+
* cap however much progress it reported, so the turn is too large, not hung.
|
|
17
|
+
*/
|
|
8
18
|
export class TurnTimeoutError extends Error {
|
|
9
|
-
constructor(
|
|
10
|
-
|
|
19
|
+
constructor(
|
|
20
|
+
public readonly ms: number,
|
|
21
|
+
public readonly kind: 'idle' | 'max' = 'idle',
|
|
22
|
+
) {
|
|
23
|
+
super(kind === 'max' ? `turn still running after ${ms}ms (hard cap)` : `turn exceeded ${ms}ms without responding`);
|
|
11
24
|
this.name = 'TurnTimeoutError';
|
|
12
25
|
}
|
|
13
26
|
}
|
|
@@ -24,18 +37,81 @@ export async function withTimeout<T>(
|
|
|
24
37
|
ms: number,
|
|
25
38
|
onTimeout?: () => void | Promise<void>,
|
|
26
39
|
): Promise<T> {
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
return withWatchdog(() => fn(), { idleMs: ms, ...(onTimeout ? { onTimeout } : {}) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The turn watchdog: `withTimeout` with the deadline measured from the last sign
|
|
45
|
+
* of progress instead of from the start. `fn` receives an `activity` callback and
|
|
46
|
+
* each call restarts the `idleMs` deadline, so a long turn that keeps producing
|
|
47
|
+
* output is never mistaken for a hung one, while a call that reports nothing gets
|
|
48
|
+
* `idleMs` as a plain whole-call deadline (unchanged for providers that cannot
|
|
49
|
+
* stream). `maxMs` is a hard cap activity does not extend, so a turn that streams
|
|
50
|
+
* forever still ends. The cap bounds a call that is producing output, so it only
|
|
51
|
+
* trips once `fn` has reported progress (a silent call is judged by `idleMs`
|
|
52
|
+
* alone, exactly as before the cap existed), and it is never shorter than
|
|
53
|
+
* `idleMs` (a lower cap would cut off a turn the idle deadline alone allows).
|
|
54
|
+
* `<= 0` (or non-finite) disables either limit; with both disabled this just
|
|
55
|
+
* awaits `fn`.
|
|
56
|
+
*/
|
|
57
|
+
export async function withWatchdog<T>(
|
|
58
|
+
fn: (activity: () => void) => Promise<T>,
|
|
59
|
+
opts: { idleMs: number; maxMs?: number; onTimeout?: () => void | Promise<void> },
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const idleOn = Number.isFinite(opts.idleMs) && opts.idleMs > 0;
|
|
62
|
+
const maxMs = opts.maxMs ?? 0;
|
|
63
|
+
const maxOn = Number.isFinite(maxMs) && maxMs > 0;
|
|
64
|
+
if (!idleOn && !maxOn) return fn(() => {});
|
|
65
|
+
const capMs = idleOn ? Math.max(maxMs, opts.idleMs) : maxMs;
|
|
66
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
let maxTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
// Set once the race is decided either way, so a late activity() from an
|
|
69
|
+
// abandoned call cannot re-arm a timer after the watchdog is done.
|
|
70
|
+
let settled = false;
|
|
71
|
+
// Whether fn has reported progress yet, and whether the cap came due while it
|
|
72
|
+
// had not (see the cap timer below).
|
|
73
|
+
let progressed = false;
|
|
74
|
+
let capPassed = false;
|
|
75
|
+
let trip!: (err: TurnTimeoutError) => void;
|
|
29
76
|
const timeout = new Promise<never>((_, reject) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
77
|
+
trip = (err) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(idleTimer);
|
|
81
|
+
clearTimeout(maxTimer);
|
|
82
|
+
// Reject before tearing down: onTimeout (provider.close()) may reject the
|
|
83
|
+
// in-flight call synchronously, and the race must settle on the timeout,
|
|
84
|
+
// not on that teardown error, or the caller never sees TurnTimeoutError.
|
|
85
|
+
reject(err);
|
|
86
|
+
void Promise.resolve(opts.onTimeout?.()).catch(() => {});
|
|
87
|
+
};
|
|
34
88
|
});
|
|
89
|
+
const armIdle = (): void => {
|
|
90
|
+
clearTimeout(idleTimer);
|
|
91
|
+
idleTimer = setTimeout(() => trip(new TurnTimeoutError(opts.idleMs, 'idle')), opts.idleMs);
|
|
92
|
+
};
|
|
93
|
+
const activity = (): void => {
|
|
94
|
+
if (settled) return;
|
|
95
|
+
progressed = true;
|
|
96
|
+
if (capPassed) return trip(new TurnTimeoutError(capMs, 'max'));
|
|
97
|
+
if (idleOn) armIdle();
|
|
98
|
+
};
|
|
99
|
+
if (idleOn) armIdle();
|
|
100
|
+
if (maxOn) {
|
|
101
|
+
maxTimer = setTimeout(() => {
|
|
102
|
+
// The cap bounds a turn that is producing output. A call that has reported
|
|
103
|
+
// nothing yet stays under the idle deadline alone; if it starts reporting
|
|
104
|
+
// progress later, the cap applies at that moment.
|
|
105
|
+
if (progressed) trip(new TurnTimeoutError(capMs, 'max'));
|
|
106
|
+
else capPassed = true;
|
|
107
|
+
}, capMs);
|
|
108
|
+
}
|
|
35
109
|
try {
|
|
36
|
-
return await Promise.race([fn(), timeout]);
|
|
110
|
+
return await Promise.race([fn(activity), timeout]);
|
|
37
111
|
} finally {
|
|
38
|
-
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(idleTimer);
|
|
114
|
+
clearTimeout(maxTimer);
|
|
39
115
|
}
|
|
40
116
|
}
|
|
41
117
|
|
|
@@ -239,7 +315,8 @@ export async function diagnoseStageFailure(
|
|
|
239
315
|
: '') +
|
|
240
316
|
'Reply with ONLY a JSON object, no prose:\n' +
|
|
241
317
|
'{"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' +
|
|
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
|
|
318
|
+
'- "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 hung provider call that timed out, a formatting slip).\n' +
|
|
319
|
+
'- a turn stopped at the hard time cap while still producing output (turnMaxMs, "too large, not hung") is NOT transient: repeating it hits the cap again. Retry only with guidance that splits that work into several smaller edits (e.g. one part or one sheet section per edit).\n' +
|
|
243
320
|
'- "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
321
|
'- 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.';
|
|
245
322
|
const messages: Msg[] = [
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { catalog } from '../capabilities/index.js';
|
|
2
|
+
import type { CatalogEntry, CatalogSkill } from '../capabilities/define.js';
|
|
3
|
+
import type { RunContext } from './context.js';
|
|
4
|
+
import { PROTOCOL_VERSION } from './envelope.js';
|
|
5
|
+
|
|
6
|
+
export { PROTOCOL_VERSION };
|
|
7
|
+
|
|
8
|
+
export class ToolRegistry {
|
|
9
|
+
private readonly byName = new Map<string, CatalogEntry>();
|
|
10
|
+
|
|
11
|
+
constructor(entries: CatalogEntry[]) {
|
|
12
|
+
for (const source of entries) {
|
|
13
|
+
const e: CatalogEntry = { ...source };
|
|
14
|
+
if (this.byName.has(e.name)) throw new Error(`duplicate catalog name "${e.name}"`);
|
|
15
|
+
this.byName.set(e.name, e);
|
|
16
|
+
}
|
|
17
|
+
for (const e of this.byName.values()) {
|
|
18
|
+
if (e.kind === 'skill' && !e.gateProvided) {
|
|
19
|
+
const conj = (ctx: RunContext) => this.conjunction(e.tools, ctx);
|
|
20
|
+
e.gate = conj;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get protocolVersion(): number {
|
|
26
|
+
return PROTOCOL_VERSION;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(name: string): CatalogEntry | undefined {
|
|
30
|
+
return this.byName.get(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
all(): CatalogEntry[] {
|
|
34
|
+
return [...this.byName.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
skills(): CatalogSkill[] {
|
|
38
|
+
return this.all().filter((e): e is CatalogSkill => e.kind === 'skill');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when every named tool exists and its gate is open. */
|
|
42
|
+
conjunction(toolNames: string[], ctx: RunContext): boolean {
|
|
43
|
+
return toolNames.every((n) => {
|
|
44
|
+
const t = this.byName.get(n);
|
|
45
|
+
return t?.kind === 'tool' && t.gate(ctx);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Entries whose gate is open. Skills also require every declared tool's gate (D3). */
|
|
50
|
+
list(ctx: RunContext): CatalogEntry[] {
|
|
51
|
+
return this.all().filter((e) => {
|
|
52
|
+
if (e.kind === 'skill' && (e.tools.includes('finish') || !this.conjunction(e.tools, ctx))) return false;
|
|
53
|
+
return e.gate(ctx);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const registry = new ToolRegistry(catalog);
|
package/src/agent/render.ts
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { copper, dim, setColorEnabled, styleOutcome, toolLine, warn } from './theme.js';
|
|
12
|
+
import type { ViewHint } from './envelope.js';
|
|
12
13
|
|
|
13
14
|
export interface ProgressRenderer {
|
|
14
15
|
log(line: string): void;
|
|
15
16
|
/** Called at the start of each turn with cumulative token totals so far. */
|
|
16
17
|
turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void;
|
|
17
|
-
toolResult(name: string, firstLine: string): void;
|
|
18
|
+
toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void;
|
|
18
19
|
/** Busy text while a provider call is in flight; null when idle. */
|
|
19
20
|
status(text: string | null): void;
|
|
20
21
|
/**
|
|
@@ -175,8 +176,8 @@ export class InteractiveRenderer implements ProgressRenderer {
|
|
|
175
176
|
this.redraw();
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
toolResult(name: string, firstLine: string): void {
|
|
179
|
-
this.log(toolLine(name, firstLine));
|
|
179
|
+
toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void {
|
|
180
|
+
this.log(toolLine(name, firstLine, ok, viewHint));
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
status(text: string | null): void {
|