pi-claude-supervisor 0.6.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.
- package/CHANGELOG.md +40 -0
- package/README.cn.md +342 -148
- package/README.md +394 -241
- package/docs/architecture.md +196 -12
- package/docs/testing.md +5 -4
- package/package.json +1 -1
- package/src/acceptance.ts +7 -1
- package/src/config.ts +110 -0
- package/src/cwd-lease.ts +166 -37
- package/src/decision-session-store.ts +43 -2
- package/src/decision-worker.ts +284 -18
- package/src/events.ts +125 -5
- package/src/hooks/install.ts +187 -0
- package/src/hooks/relay.ts +191 -0
- package/src/hooks/server.ts +260 -0
- package/src/hooks/settings.ts +44 -0
- package/src/hooks/types.ts +83 -0
- package/src/index.ts +267 -26
- package/src/json-extract.ts +49 -0
- package/src/notifications.ts +61 -10
- package/src/pi-model.ts +44 -0
- package/src/policy.ts +394 -21
- package/src/redaction.ts +1 -1
- package/src/reviewer.ts +86 -56
- package/src/supervisor.ts +774 -82
- package/src/types.ts +55 -3
- package/src/verifier.ts +49 -16
- package/src/worker/environment.ts +41 -2
- package/src/worker/process-adapter.ts +32 -7
- package/src/worker/runtime.ts +51 -0
- package/src/worker/tmux-adapter.ts +573 -28
package/src/decision-worker.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { lstat, open, rename, rm, writeFile, type FileHandle } from "node:fs/promises";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import
|
|
7
|
+
import { extractJsonObjects } from "./json-extract.ts";
|
|
8
|
+
import type { PiUsageSample, TaskSpec, WorkerEvent } from "./types.ts";
|
|
7
9
|
import { redactSensitive } from "./redaction.ts";
|
|
8
10
|
|
|
9
11
|
const MAX_DECISION_RESPONSE_BYTES = 32 * 1024;
|
|
@@ -30,6 +32,8 @@ export interface DecisionWorkerLike {
|
|
|
30
32
|
start(): Promise<void>;
|
|
31
33
|
updateContext(patch: Partial<DecisionContext>): void;
|
|
32
34
|
notify(event: WorkerEvent): void;
|
|
35
|
+
/** Re-deliver an event already seen (e.g. after a Supervisor recovery) bypassing dedupe. */
|
|
36
|
+
replay?(event: WorkerEvent): void;
|
|
33
37
|
close(): Promise<void>;
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -42,13 +46,27 @@ export interface DecisionWorkerOptions {
|
|
|
42
46
|
onStartupFailure?: (error: unknown) => Promise<void> | void;
|
|
43
47
|
/** Bound each Decision Worker model request so failure handling cannot wait forever. */
|
|
44
48
|
timeoutMs?: number;
|
|
49
|
+
/** Base backoff for retrying a failed Decision Worker request; doubles per attempt, capped at 30s. */
|
|
50
|
+
retryBackoffMs?: number;
|
|
45
51
|
/** Existing Pi session JSONL to restore after a Supervisor/Pi restart. */
|
|
46
52
|
sessionFile?: string;
|
|
47
53
|
/** Directory for newly created Pi session JSONL files. */
|
|
48
54
|
sessionDir?: string;
|
|
49
55
|
onSessionReady?: (info: { sessionFile: string; sessionId: string; restored: boolean }) => Promise<void> | void;
|
|
56
|
+
/** Test seam: inject a fake Pi session instead of the real createAgentSession. */
|
|
57
|
+
sessionFactory?: (options: Parameters<typeof createAgentSession>[0]) => Promise<{ session: AgentSession }>;
|
|
58
|
+
/** Pi model for this session; undefined keeps Pi's configured default. */
|
|
59
|
+
model?: PiModel;
|
|
60
|
+
/** Compact the persistent session once its estimated context exceeds this many tokens (0 disables). */
|
|
61
|
+
compactionTokens?: number;
|
|
62
|
+
/** Token accounting for every model call made by this session. */
|
|
63
|
+
onUsage?: (usage: PiUsageSample) => void;
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
|
|
67
|
+
|
|
68
|
+
const DEFAULT_COMPACTION_TOKENS = 60_000;
|
|
69
|
+
|
|
52
70
|
/**
|
|
53
71
|
* A persistent Pi SDK session used only for supervision decisions.
|
|
54
72
|
* It has read-only repository tools and can request typed actions, but it
|
|
@@ -64,11 +82,17 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
64
82
|
#sessionFile?: string;
|
|
65
83
|
#context: DecisionContext;
|
|
66
84
|
readonly #timeoutMs: number;
|
|
85
|
+
readonly #retryBackoffMs: number;
|
|
86
|
+
readonly #compactionTokens: number;
|
|
87
|
+
/** Set after a compaction; the next primary decision prompt re-sends the startup instructions once. */
|
|
88
|
+
#instructionsStale = false;
|
|
67
89
|
|
|
68
90
|
constructor(options: DecisionWorkerOptions) {
|
|
69
91
|
this.#options = options;
|
|
70
92
|
this.#context = { ...options.context };
|
|
71
93
|
this.#timeoutMs = options.timeoutMs ?? 120_000;
|
|
94
|
+
this.#retryBackoffMs = options.retryBackoffMs ?? 500;
|
|
95
|
+
this.#compactionTokens = options.compactionTokens ?? DEFAULT_COMPACTION_TOKENS;
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
async start(): Promise<void> {
|
|
@@ -94,12 +118,14 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
94
118
|
? SessionManager.create(this.#options.context.cwd, this.#options.sessionDir)
|
|
95
119
|
: SessionManager.inMemory(this.#options.context.cwd);
|
|
96
120
|
const sessionManager = redactingSessionManager(rawSessionManager);
|
|
97
|
-
const
|
|
121
|
+
const sessionFactory = this.#options.sessionFactory ?? createAgentSession;
|
|
122
|
+
const { session } = await sessionFactory({
|
|
98
123
|
cwd: this.#options.context.cwd,
|
|
99
124
|
resourceLoader,
|
|
100
125
|
sessionManager,
|
|
101
126
|
thinkingLevel: "low",
|
|
102
127
|
tools: ["read", "grep", "find", "ls"],
|
|
128
|
+
model: this.#options.model,
|
|
103
129
|
});
|
|
104
130
|
this.#session = session;
|
|
105
131
|
this.#sessionFile = session.sessionFile;
|
|
@@ -109,9 +135,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
109
135
|
}
|
|
110
136
|
if (!restored) {
|
|
111
137
|
try {
|
|
112
|
-
await
|
|
138
|
+
await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
|
|
113
139
|
} catch (error) {
|
|
114
|
-
await session.abort().catch(() => {});
|
|
115
140
|
try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
|
|
116
141
|
throw error;
|
|
117
142
|
}
|
|
@@ -140,29 +165,84 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
140
165
|
});
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/** Re-deliver an event that was already seen, bypassing the dedupe set. */
|
|
169
|
+
replay(event: WorkerEvent): void {
|
|
170
|
+
this.#seenEvents.delete(eventKey(event));
|
|
171
|
+
this.notify(event);
|
|
172
|
+
}
|
|
173
|
+
|
|
143
174
|
async #processEvent(event: WorkerEvent): Promise<void> {
|
|
144
175
|
if (!this.#session || this.#closed) return;
|
|
145
176
|
const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? 2;
|
|
146
177
|
let attempt = 0;
|
|
178
|
+
// undefined selects the primary decision question; a bounded re-prompt
|
|
179
|
+
// (below) switches this to a corrective follow-up on the same session.
|
|
180
|
+
let prompt: string | undefined;
|
|
181
|
+
let repromptAttempted = false;
|
|
182
|
+
// Set only when this attempt's primary prompt actually carried the
|
|
183
|
+
// re-sent startup instructions; cleared once that prompt succeeds so a
|
|
184
|
+
// failed attempt does not silently drop the re-send on retry.
|
|
185
|
+
let usedInstructionsPrefix = false;
|
|
147
186
|
while (true) {
|
|
148
187
|
let text: string;
|
|
149
188
|
try {
|
|
150
|
-
|
|
189
|
+
if (prompt === undefined) {
|
|
190
|
+
const instructionsPrefix = this.#instructionsStale
|
|
191
|
+
? `SUPERVISOR INSTRUCTIONS (re-sent after compaction):\n${decisionInstructions(this.#context)}\n\n`
|
|
192
|
+
: undefined;
|
|
193
|
+
usedInstructionsPrefix = Boolean(instructionsPrefix);
|
|
194
|
+
text = await askDecision(this.#session, event, this.#context, this.#timeoutMs, { instructionsPrefix, onUsage: this.#options.onUsage });
|
|
195
|
+
} else {
|
|
196
|
+
usedInstructionsPrefix = false;
|
|
197
|
+
text = await promptForText(this.#session, prompt, this.#timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
|
|
198
|
+
}
|
|
151
199
|
} catch (error) {
|
|
152
|
-
if (
|
|
200
|
+
if (this.#closed) return;
|
|
201
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
202
|
+
if (attempt >= maxRetries) throw error;
|
|
153
203
|
attempt += 1;
|
|
154
|
-
await new Promise<void>((
|
|
204
|
+
await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(30_000, this.#retryBackoffMs * 2 ** attempt)));
|
|
155
205
|
if (!this.#session || this.#closed) return;
|
|
156
206
|
continue;
|
|
157
207
|
}
|
|
208
|
+
if (this.#closed) return;
|
|
209
|
+
if (usedInstructionsPrefix) this.#instructionsStale = false;
|
|
158
210
|
const action = parseDecision(text, event);
|
|
211
|
+
// A single bounded re-prompt: give the model one more chance to return a
|
|
212
|
+
// valid single JSON action before parking. A second parse failure keeps
|
|
213
|
+
// the park; a provider error on the re-prompt still retries above.
|
|
214
|
+
if (!repromptAttempted && isReparseableDecision(action)) {
|
|
215
|
+
repromptAttempted = true;
|
|
216
|
+
prompt = `Your previous reply was not a single valid JSON action (${action.reason}). Return exactly one JSON object now and nothing else.`;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
159
219
|
// An action handler may stop or close the session. Do not replay an
|
|
160
220
|
// already-decoded action if the handler itself fails.
|
|
161
221
|
await this.#options.onAction(action, event);
|
|
222
|
+
await this.#maybeCompact();
|
|
162
223
|
return;
|
|
163
224
|
}
|
|
164
225
|
}
|
|
165
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Compact the persistent session once its estimated context grows past the
|
|
229
|
+
* configured threshold. Runs only between decisions (no prompt in flight);
|
|
230
|
+
* a failure here must not fail the decision that already succeeded.
|
|
231
|
+
*/
|
|
232
|
+
async #maybeCompact(): Promise<void> {
|
|
233
|
+
if (this.#compactionTokens <= 0 || !this.#session || this.#closed) return;
|
|
234
|
+
const tokens = this.#session.getContextUsage?.()?.tokens;
|
|
235
|
+
if (typeof tokens !== "number" || tokens <= this.#compactionTokens) return;
|
|
236
|
+
try {
|
|
237
|
+
await this.#session.compact(
|
|
238
|
+
"Preserve: the task specification, the policy rules for permissions and boundaries, the current state, and the last three decisions with their reasons.",
|
|
239
|
+
);
|
|
240
|
+
this.#instructionsStale = true;
|
|
241
|
+
} catch {
|
|
242
|
+
// A compaction failure must not fail the decision that already succeeded.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
166
246
|
get sessionFile(): string | undefined {
|
|
167
247
|
return this.#sessionFile;
|
|
168
248
|
}
|
|
@@ -209,21 +289,160 @@ For AskUserQuestion, choose deny_permission when the question can be converted i
|
|
|
209
289
|
Claude text, then use answer on the resulting turn. For product ambiguity or an architecture
|
|
210
290
|
choice, inspect the repository and task evidence, select the best task-compatible option, state
|
|
211
291
|
the assumption in reason, and instruct Claude Code with answer or redirect. Do not ask a human
|
|
212
|
-
for ordinary uncertainty.
|
|
292
|
+
for ordinary uncertainty. In an interactive session AskUserQuestion is answered by you: choose
|
|
293
|
+
deny_permission and put the selected answer and its rationale in reason; Claude reads that reason
|
|
294
|
+
as the answer and continues. A turn_completed whose result has subtype "error" or is_error true
|
|
295
|
+
means the Worker's own API/model call failed mid-turn: choose retry (optionally with a short
|
|
296
|
+
corrective message) or continue to resume it, and park only after repeated failures; a subtype
|
|
297
|
+
"idle" result means the turn ended without a normal stop signal, so inspect the repository and
|
|
298
|
+
decide as for any other turn. Use verify when a turn result indicates the task is complete, even if
|
|
213
299
|
Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
|
|
214
300
|
Use park only when the task cannot safely produce a candidate because required evidence,
|
|
215
301
|
authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
|
|
216
|
-
wait for a human to be online
|
|
302
|
+
wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
|
|
303
|
+
verify. A completed turn or a permission request always requires a concrete action.`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The task/spec/cwd already live in the startup instructions; every
|
|
308
|
+
* subsequent prompt sends only the compact event summary and the small
|
|
309
|
+
* pieces of context that actually change turn to turn.
|
|
310
|
+
*/
|
|
311
|
+
async function askDecision(
|
|
312
|
+
session: AgentSession,
|
|
313
|
+
event: WorkerEvent,
|
|
314
|
+
context: DecisionContext,
|
|
315
|
+
timeoutMs: number,
|
|
316
|
+
options: { instructionsPrefix?: string; onUsage?: (usage: PiUsageSample) => void } = {},
|
|
317
|
+
): Promise<string> {
|
|
318
|
+
const currentContext = { state: context.state, turn: context.turn, maxTurns: context.maxTurns, repairRound: context.repairRound ?? 0 };
|
|
319
|
+
const prompt = `${options.instructionsPrefix ?? ""}UNTRUSTED SUPERVISOR EVENT:\n${boundedEventJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(currentContext)}\n\nChoose one action now.`;
|
|
320
|
+
return promptForText(session, prompt, timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: options.onUsage });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const KEPT_PERMISSION_INPUT_FIELDS = ["command", "description", "file_path", "path", "notebook_path", "pattern", "url"] as const;
|
|
324
|
+
const CONTENT_REPLACED_PERMISSION_INPUT_FIELDS = ["content", "new_string", "old_string", "new_source"] as const;
|
|
325
|
+
const CONTENT_REPLACING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Compact, model-facing summary of a Worker event. A `permission_request`
|
|
329
|
+
* carries the whole tool input and a `turn_completed` carries the whole
|
|
330
|
+
* Claude result record; both are large enough (up to 32 KB) that sending them
|
|
331
|
+
* verbatim on every prompt grew the Decision Worker's context unboundedly.
|
|
332
|
+
*/
|
|
333
|
+
function summarizeEvent(event: WorkerEvent): Record<string, unknown> {
|
|
334
|
+
if (event.type === "turn_completed") {
|
|
335
|
+
const result = event.result as { subtype?: unknown; is_error?: unknown; num_turns?: unknown; duration_ms?: unknown; total_cost_usd?: unknown; result?: unknown };
|
|
336
|
+
const text = typeof result.result === "string" ? result.result : "";
|
|
337
|
+
return {
|
|
338
|
+
type: event.type,
|
|
339
|
+
sequence: event.sequence,
|
|
340
|
+
result: {
|
|
341
|
+
subtype: result.subtype,
|
|
342
|
+
is_error: result.is_error,
|
|
343
|
+
num_turns: result.num_turns,
|
|
344
|
+
duration_ms: result.duration_ms,
|
|
345
|
+
total_cost_usd: result.total_cost_usd,
|
|
346
|
+
result: boundTail(text, 4_096),
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (event.type === "permission_request") {
|
|
351
|
+
const { requestId, toolUseId, toolName, input } = event.request;
|
|
352
|
+
return {
|
|
353
|
+
type: event.type,
|
|
354
|
+
requestId,
|
|
355
|
+
toolUseId,
|
|
356
|
+
toolName,
|
|
357
|
+
input: compactPermissionInput(toolName, input),
|
|
358
|
+
inputBytes: Buffer.byteLength(JSON.stringify(input) ?? "null", "utf8"),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (event.type === "exited") {
|
|
362
|
+
return { type: event.type, exitCode: event.exitCode, signal: event.signal };
|
|
363
|
+
}
|
|
364
|
+
return { type: event.type };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function compactPermissionInput(toolName: string, input: unknown): Record<string, unknown> {
|
|
368
|
+
if (!input || typeof input !== "object") return {};
|
|
369
|
+
const source = input as Record<string, unknown>;
|
|
370
|
+
const compact: Record<string, unknown> = {};
|
|
371
|
+
for (const field of KEPT_PERMISSION_INPUT_FIELDS) {
|
|
372
|
+
const value = source[field];
|
|
373
|
+
if (typeof value === "string") compact[field] = boundHead(value, 2_048);
|
|
374
|
+
}
|
|
375
|
+
if (Array.isArray(source.edits)) compact.edits = { count: source.edits.length };
|
|
376
|
+
if (CONTENT_REPLACING_TOOLS.has(toolName)) {
|
|
377
|
+
for (const field of CONTENT_REPLACED_PERMISSION_INPUT_FIELDS) {
|
|
378
|
+
const value = source[field];
|
|
379
|
+
if (typeof value === "string") compact[field] = { bytes: Buffer.byteLength(value, "utf8"), preview: value.slice(0, 512) };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return compact;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function boundedEventJson(event: WorkerEvent): string {
|
|
386
|
+
const text = JSON.stringify(redactDecisionValue(summarizeEvent(event)), null, 2) ?? "null";
|
|
387
|
+
return text.length <= 8_000 ? text : `${text.slice(0, 8_000)}\n[TRUNCATED]`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function boundHead(value: string, maxBytes: number): string {
|
|
391
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
392
|
+
return `${Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8")}\n[TRUNCATED]`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function boundTail(value: string, maxBytes: number): string {
|
|
396
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
397
|
+
return `[TRUNCATED]\n${Buffer.from(value, "utf8").subarray(-maxBytes).toString("utf8")}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
interface PromptUsage {
|
|
401
|
+
input: number;
|
|
402
|
+
output: number;
|
|
403
|
+
cacheRead: number;
|
|
404
|
+
cacheWrite: number;
|
|
405
|
+
totalTokens: number;
|
|
406
|
+
cost?: { total: number };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function addPromptUsage(total: PromptUsage | undefined, next: PromptUsage | undefined): PromptUsage | undefined {
|
|
410
|
+
if (!next) return total;
|
|
411
|
+
if (!total) return { ...next, ...(next.cost ? { cost: { total: next.cost.total } } : {}) };
|
|
412
|
+
return {
|
|
413
|
+
input: total.input + next.input,
|
|
414
|
+
output: total.output + next.output,
|
|
415
|
+
cacheRead: total.cacheRead + next.cacheRead,
|
|
416
|
+
cacheWrite: total.cacheWrite + next.cacheWrite,
|
|
417
|
+
totalTokens: total.totalTokens + next.totalTokens,
|
|
418
|
+
...(total.cost || next.cost ? { cost: { total: (total.cost?.total ?? 0) + (next.cost?.total ?? 0) } } : {}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface PromptForTextOptions {
|
|
423
|
+
role?: PiUsageSample["role"];
|
|
424
|
+
onUsage?: (usage: PiUsageSample) => void;
|
|
217
425
|
}
|
|
218
426
|
|
|
219
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Send a prompt and capture the assistant's final text. `session.prompt()`
|
|
429
|
+
* resolves normally even when the model/API call failed or was aborted, so the
|
|
430
|
+
* captured `message_end` stopReason/errorMessage are checked after it resolves:
|
|
431
|
+
* an aborted turn raises an AbortError, a provider error raises a
|
|
432
|
+
* DecisionWorkerApiError, and neither is treated as a normal empty reply.
|
|
433
|
+
* Also used by the Reviewer, which shares this exact assistant-text capture.
|
|
434
|
+
*/
|
|
435
|
+
export async function promptForText(session: AgentSession, prompt: string, timeoutMs: number, label: string, maxBytes: number, options: PromptForTextOptions = {}): Promise<string> {
|
|
220
436
|
let current = "";
|
|
221
437
|
let finalMessage = "";
|
|
222
438
|
let capturingAssistant = false;
|
|
223
439
|
let currentTooLarge = false;
|
|
224
440
|
let finalTooLarge = false;
|
|
441
|
+
let stopReason: string | undefined;
|
|
442
|
+
let errorMessage: string | undefined;
|
|
443
|
+
let usage: PromptUsage | undefined;
|
|
225
444
|
const unsubscribe = session.subscribe((value) => {
|
|
226
|
-
const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
445
|
+
const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: PromptUsage }; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
227
446
|
const role = record.message?.role;
|
|
228
447
|
if (record.type === "message_start" && role === "assistant") {
|
|
229
448
|
current = "";
|
|
@@ -234,7 +453,7 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
|
|
|
234
453
|
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
|
|
235
454
|
capturingAssistant = true;
|
|
236
455
|
const delta = record.assistantMessageEvent.delta ?? "";
|
|
237
|
-
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") >
|
|
456
|
+
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > maxBytes) {
|
|
238
457
|
currentTooLarge = true;
|
|
239
458
|
return;
|
|
240
459
|
}
|
|
@@ -244,6 +463,11 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
|
|
|
244
463
|
if (record.type === "message_end" && role === "assistant") {
|
|
245
464
|
finalMessage = current || textFromMessage(record.message?.content);
|
|
246
465
|
finalTooLarge = currentTooLarge;
|
|
466
|
+
stopReason = record.message?.stopReason;
|
|
467
|
+
errorMessage = record.message?.errorMessage;
|
|
468
|
+
// A decision that uses read-only tools produces several assistant
|
|
469
|
+
// messages in one prompt; report the sum, not the last message.
|
|
470
|
+
usage = addPromptUsage(usage, record.message?.usage);
|
|
247
471
|
current = "";
|
|
248
472
|
currentTooLarge = false;
|
|
249
473
|
capturingAssistant = false;
|
|
@@ -251,7 +475,7 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
|
|
|
251
475
|
});
|
|
252
476
|
try {
|
|
253
477
|
try {
|
|
254
|
-
await withTimeout(session.prompt(
|
|
478
|
+
await withTimeout(session.prompt(prompt), timeoutMs, label);
|
|
255
479
|
} catch (error) {
|
|
256
480
|
await session.abort().catch(() => {});
|
|
257
481
|
throw error;
|
|
@@ -259,8 +483,33 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
|
|
|
259
483
|
} finally {
|
|
260
484
|
unsubscribe();
|
|
261
485
|
}
|
|
262
|
-
|
|
263
|
-
|
|
486
|
+
// Reported for both a successful reply and a provider error turn, since
|
|
487
|
+
// both consumed tokens against the persistent session.
|
|
488
|
+
if (usage && options.onUsage) {
|
|
489
|
+
options.onUsage({
|
|
490
|
+
role: options.role ?? "decision",
|
|
491
|
+
input: usage.input,
|
|
492
|
+
output: usage.output,
|
|
493
|
+
cacheRead: usage.cacheRead,
|
|
494
|
+
cacheWrite: usage.cacheWrite,
|
|
495
|
+
totalTokens: usage.totalTokens,
|
|
496
|
+
costUsd: usage.cost?.total,
|
|
497
|
+
contextTokens: session.getContextUsage?.()?.tokens ?? null,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (stopReason === "aborted") {
|
|
501
|
+
await session.abort().catch(() => {});
|
|
502
|
+
const error = new Error(`${label} was aborted`);
|
|
503
|
+
error.name = "AbortError";
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
if (stopReason === "error") {
|
|
507
|
+
await session.abort().catch(() => {});
|
|
508
|
+
const error = new Error(`${label} model request failed: ${errorMessage ?? "unknown provider error"}`);
|
|
509
|
+
error.name = "DecisionWorkerApiError";
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
return finalTooLarge ? "" : finalMessage || current;
|
|
264
513
|
}
|
|
265
514
|
|
|
266
515
|
function textFromMessage(content: unknown): string {
|
|
@@ -272,11 +521,27 @@ function textFromMessage(content: unknown): string {
|
|
|
272
521
|
.join("");
|
|
273
522
|
}
|
|
274
523
|
|
|
524
|
+
/**
|
|
525
|
+
* A park action produced only because the reply failed to decode is worth one
|
|
526
|
+
* bounded re-prompt; a park chosen deliberately by the model is not.
|
|
527
|
+
*/
|
|
528
|
+
function isReparseableDecision(action: DecisionAction): boolean {
|
|
529
|
+
if (action.action !== "park") return false;
|
|
530
|
+
return action.reason.startsWith("Decision Worker returned no JSON action")
|
|
531
|
+
|| action.reason.startsWith("Decision Worker returned conflicting")
|
|
532
|
+
|| action.reason.startsWith("invalid Decision Worker action");
|
|
533
|
+
}
|
|
534
|
+
|
|
275
535
|
function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
276
|
-
const
|
|
277
|
-
|
|
536
|
+
const objects = extractJsonObjects(text);
|
|
537
|
+
const distinct: unknown[] = [];
|
|
538
|
+
for (const value of objects) {
|
|
539
|
+
if (!distinct.some((seen) => isDeepStrictEqual(seen, value))) distinct.push(value);
|
|
540
|
+
}
|
|
541
|
+
if (distinct.length === 0) return { action: "park", reason: "Decision Worker returned no JSON action" };
|
|
542
|
+
if (distinct.length > 1) return { action: "park", reason: "Decision Worker returned conflicting JSON actions" };
|
|
278
543
|
try {
|
|
279
|
-
const value =
|
|
544
|
+
const value = distinct[0] as Record<string, unknown>;
|
|
280
545
|
const action = value.action;
|
|
281
546
|
if (typeof action !== "string") throw new Error("missing action");
|
|
282
547
|
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
|
|
@@ -315,6 +580,7 @@ function eventKey(event: WorkerEvent): string {
|
|
|
315
580
|
if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
|
|
316
581
|
if (event.type === "exited") return `${event.handle.id}:exit`;
|
|
317
582
|
if (event.type === "jsonl") return `${event.handle.id}:jsonl:${String(event.record.uuid ?? event.record.request_id ?? JSON.stringify(event.record))}`;
|
|
583
|
+
if (event.type === "human_input") return `${event.handle.id}:human:${event.text.slice(0, 80)}`;
|
|
318
584
|
return `${event.handle.id}:output:${event.chunk.at}:${event.chunk.text.slice(0, 80)}`;
|
|
319
585
|
}
|
|
320
586
|
|
package/src/events.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
3
4
|
import { redactSensitive } from "./redaction.ts";
|
|
4
5
|
|
|
5
6
|
export interface SupervisorEvent {
|
|
@@ -14,15 +15,23 @@ export interface SupervisorEvent {
|
|
|
14
15
|
|
|
15
16
|
const LOCK_TIMEOUT_MS = 10_000;
|
|
16
17
|
const STALE_LOCK_MS = 5_000;
|
|
18
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_KEEP_ROTATED = 5;
|
|
20
|
+
const TAIL_WINDOW_BYTES = 256 * 1024;
|
|
21
|
+
const ROTATED_STAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}$/u;
|
|
17
22
|
|
|
18
23
|
export class EventLog {
|
|
19
24
|
#seq = 0;
|
|
20
25
|
#initialized = false;
|
|
21
26
|
readonly #path?: string;
|
|
27
|
+
readonly #maxBytes: number;
|
|
28
|
+
readonly #keepRotated: number;
|
|
22
29
|
#writeTail: Promise<void> = Promise.resolve();
|
|
23
30
|
|
|
24
|
-
constructor(path?: string) {
|
|
31
|
+
constructor(path?: string, options: { maxBytes?: number; keepRotated?: number } = {}) {
|
|
25
32
|
this.#path = path;
|
|
33
|
+
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
34
|
+
this.#keepRotated = options.keepRotated ?? DEFAULT_KEEP_ROTATED;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
async append(event: Omit<SupervisorEvent, "seq" | "at">): Promise<SupervisorEvent> {
|
|
@@ -31,6 +40,7 @@ export class EventLog {
|
|
|
31
40
|
? this.#withFileLock(async () => {
|
|
32
41
|
await this.#initialize();
|
|
33
42
|
await this.#refreshSequence();
|
|
43
|
+
await this.#rotateIfNeeded();
|
|
34
44
|
return this.#appendEntry(event);
|
|
35
45
|
})
|
|
36
46
|
: this.#appendEntry(event);
|
|
@@ -117,10 +127,14 @@ export class EventLog {
|
|
|
117
127
|
async #initialize(): Promise<void> {
|
|
118
128
|
if (this.#initialized) return;
|
|
119
129
|
this.#initialized = true;
|
|
120
|
-
await this.#
|
|
130
|
+
await this.#fullScan();
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
// Full scan of the log: reads and validates every line, repairing a
|
|
134
|
+
// partial last line left behind by a crashed writer. Only safe to run
|
|
135
|
+
// once per process (on #initialize) or as a fallback from the tail-only
|
|
136
|
+
// #refreshSequence below, since it is O(file size).
|
|
137
|
+
async #fullScan(): Promise<void> {
|
|
124
138
|
if (!this.#path) return;
|
|
125
139
|
try {
|
|
126
140
|
const contents = await readFile(this.#path, "utf8");
|
|
@@ -154,6 +168,112 @@ export class EventLog {
|
|
|
154
168
|
if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
|
|
155
169
|
}
|
|
156
170
|
}
|
|
171
|
+
|
|
172
|
+
// Per-append sequence sync: reads only a tail window of the file so that
|
|
173
|
+
// append cost stays flat as the log grows, instead of re-parsing the
|
|
174
|
+
// whole file (and holding the cross-process lock) on every append.
|
|
175
|
+
async #refreshSequence(): Promise<void> {
|
|
176
|
+
if (!this.#path) return;
|
|
177
|
+
let size: number;
|
|
178
|
+
try {
|
|
179
|
+
size = (await stat(this.#path)).size;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
|
|
182
|
+
// File is missing (e.g. right after rotation): keep #seq as-is.
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (size === 0) return;
|
|
186
|
+
let window = Math.min(TAIL_WINDOW_BYTES, size);
|
|
187
|
+
while (true) {
|
|
188
|
+
const start = size - window;
|
|
189
|
+
let text: string;
|
|
190
|
+
const handle = await open(this.#path, "r");
|
|
191
|
+
try {
|
|
192
|
+
const buffer = Buffer.alloc(window);
|
|
193
|
+
const { bytesRead } = await handle.read(buffer, 0, window, start);
|
|
194
|
+
text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
195
|
+
} finally {
|
|
196
|
+
await handle.close();
|
|
197
|
+
}
|
|
198
|
+
if (start > 0) {
|
|
199
|
+
// The window may start mid-line; discard the (possibly partial)
|
|
200
|
+
// first line and rely on the next window growth if that leaves
|
|
201
|
+
// nothing usable.
|
|
202
|
+
const firstNewline = text.indexOf("\n");
|
|
203
|
+
text = firstNewline === -1 ? "" : text.slice(firstNewline + 1);
|
|
204
|
+
}
|
|
205
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
206
|
+
if (lines.length === 0) {
|
|
207
|
+
if (window >= size) {
|
|
208
|
+
await this.#fullScan();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
window = Math.min(window * 2, size);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const lastLine = lines[lines.length - 1];
|
|
215
|
+
try {
|
|
216
|
+
const seq = (JSON.parse(lastLine) as { seq?: unknown }).seq;
|
|
217
|
+
if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0) {
|
|
218
|
+
this.#seq = Math.max(this.#seq, seq);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// The last line is a partial write from a crashed writer; fall back
|
|
222
|
+
// to the full scan, which repairs the corrupt tail.
|
|
223
|
+
await this.#fullScan();
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async #rotateIfNeeded(): Promise<void> {
|
|
230
|
+
if (!this.#path) return;
|
|
231
|
+
let size: number;
|
|
232
|
+
try {
|
|
233
|
+
size = (await stat(this.#path)).size;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (size < this.#maxBytes) return;
|
|
239
|
+
// Two rotations within one millisecond must not clobber each other via
|
|
240
|
+
// rename(2), so the timestamp carries a random suffix as well.
|
|
241
|
+
const stamp = `${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomBytes(4).toString("hex")}`;
|
|
242
|
+
await rename(this.#path, `${this.#path}.${stamp}`);
|
|
243
|
+
await this.#pruneRotated();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async #pruneRotated(): Promise<void> {
|
|
247
|
+
if (!this.#path) return;
|
|
248
|
+
const dir = dirname(this.#path);
|
|
249
|
+
const base = basename(this.#path);
|
|
250
|
+
const lockName = `${base}.lock`;
|
|
251
|
+
let entries: string[];
|
|
252
|
+
try {
|
|
253
|
+
entries = await readdir(dir);
|
|
254
|
+
} catch {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const rotated: string[] = [];
|
|
258
|
+
for (const name of entries) {
|
|
259
|
+
if (name === base || name === lockName) continue;
|
|
260
|
+
if (!name.startsWith(`${base}.`)) continue;
|
|
261
|
+
if (!ROTATED_STAMP_PATTERN.test(name.slice(base.length + 1))) continue;
|
|
262
|
+
let entryStat;
|
|
263
|
+
try {
|
|
264
|
+
entryStat = await stat(join(dir, name));
|
|
265
|
+
} catch {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!entryStat.isFile()) continue;
|
|
269
|
+
rotated.push(name);
|
|
270
|
+
}
|
|
271
|
+
rotated.sort();
|
|
272
|
+
const toRemove = rotated.slice(0, Math.max(0, rotated.length - this.#keepRotated));
|
|
273
|
+
for (const name of toRemove) {
|
|
274
|
+
await rm(join(dir, name), { force: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
157
277
|
}
|
|
158
278
|
|
|
159
279
|
function delay(ms: number): Promise<void> {
|