pi-background-tasks 0.7.6 → 0.7.7

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.
@@ -0,0 +1,673 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { mkdirSync, openSync, closeSync, fsyncSync, renameSync, writeSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
5
+ import { Type, type Static } from 'typebox';
6
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
7
+ import type { Usage } from '@earendil-works/pi-ai';
8
+ import {
9
+ DELEGATE_RECEIPT_SCHEMA_VERSION,
10
+ type DelegateRouteAttestation,
11
+ type DelegateSeedV1,
12
+ type DelegateSpillReceipt,
13
+ type DelegateUsageReport,
14
+ } from './core/delegate/types.js';
15
+ import { verifyDelegateSeedBytes } from './core/delegate/seed.js';
16
+ import { evaluateDelegateRuntimeBudget } from './core/delegate/budget.js';
17
+ import { utf8ByteClassBreakdown } from './core/context/token-budget.js';
18
+ import {
19
+ buildDelegateResultPackage,
20
+ serializeDelegateResultPackage,
21
+ } from './core/delegate/result-package.js';
22
+
23
+ /**
24
+ * Package-owned delegate child extension.
25
+ *
26
+ * This runs inside the delegate child Pi process and is the only extension it
27
+ * loads. It is responsible for every guarantee that cannot be enforced from the
28
+ * parent:
29
+ *
30
+ * - verifying the frozen seed bytes before the first model call;
31
+ * - measuring the outgoing message set before every model call and refusing to
32
+ * let an oversized one reach the provider;
33
+ * - spilling oversized tool results to hashed artifacts and replacing them with
34
+ * explicit receipts before they enter the transcript;
35
+ * - asserting every assistant message came from the pinned route;
36
+ * - enforcing turn and tool-call limits;
37
+ * - committing exactly one result package atomically.
38
+ *
39
+ * Measured Pi 0.83 behaviour this design accounts for (see
40
+ * `tests/scripted-provider/pi-hook-contract.test.ts`):
41
+ *
42
+ * - Throwing from a `context` handler does NOT stop the provider call. Pi
43
+ * catches the exception and dispatches anyway. A throw is therefore never used
44
+ * as a barrier here.
45
+ * - `ctx.abort()` does not skip the provider call site, but the call receives an
46
+ * already-aborted signal and the run terminates. That is the barrier used.
47
+ * - Because neither mechanism is a hard admission gate on its own, the guard
48
+ * ALSO replaces the offending content in the returned message set. Even a
49
+ * provider that ignored the aborted signal could not transmit the content,
50
+ * because the content is no longer there.
51
+ */
52
+
53
+ const SPILL_DIRNAME = 'spill';
54
+
55
+ interface GuardState {
56
+ seed: DelegateSeedV1;
57
+ artifactDirAbs: string;
58
+ turns: number;
59
+ toolCalls: number;
60
+ totalToolOutputBytes: number;
61
+ spilled: DelegateSpillReceipt[];
62
+ attestations: DelegateRouteAttestation[];
63
+ usage: Usage | undefined;
64
+ usageUnavailableReason: string | undefined;
65
+ answerBlocks: string[];
66
+ terminal: TerminalLatch | undefined;
67
+ committed: boolean;
68
+ }
69
+
70
+ /**
71
+ * A terminal condition latches.
72
+ *
73
+ * Once the guard has degraded or refused anything, no later assistant message
74
+ * may be committed as a successful answer. Otherwise a run whose context was
75
+ * silently mutilated could still produce a hash-valid package, which is exactly
76
+ * the "hash-valid but wrong" failure this design must not have.
77
+ */
78
+ interface TerminalLatch {
79
+ code: string;
80
+ message: string;
81
+ }
82
+
83
+ /**
84
+ * Stop reasons that may be committed as a complete answer.
85
+ *
86
+ * `length` means the provider truncated the response at the output-token limit,
87
+ * `aborted` and `error` mean the run did not finish, and a pending tool call
88
+ * means the agent had more to do. None of those is a whole answer, so none of
89
+ * them may be committed as one.
90
+ */
91
+ const ACCEPTED_STOP_REASONS: ReadonlySet<string> = new Set(['stop']);
92
+
93
+ function sha256(value: Buffer): string {
94
+ return createHash('sha256').update(value).digest('hex');
95
+ }
96
+
97
+ /**
98
+ * Replacement message set used when the guard suppresses a request.
99
+ *
100
+ * Keeps the shape valid without transmitting the content that triggered the
101
+ * suppression. The head message is retained only when it is a user message, so
102
+ * a suppressed request cannot carry assistant or tool content forward.
103
+ */
104
+ function suppressedMessages<TMessage extends object>(
105
+ messages: readonly TMessage[],
106
+ ): TMessage[] {
107
+ const head = messages[0];
108
+ if (head === undefined) return [];
109
+ return Reflect.get(head, 'role') === 'user' ? [head] : [];
110
+ }
111
+
112
+ function utf8(value: string): Buffer {
113
+ return Buffer.from(value, 'utf8');
114
+ }
115
+
116
+ function finiteNonNegative(source: object, key: string): number | undefined {
117
+ const value: unknown = Reflect.get(source, key);
118
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
119
+ return value;
120
+ }
121
+
122
+ /**
123
+ * Read a complete Pi `Usage` record, or report none.
124
+ *
125
+ * A partial usage record is treated as no usage at all. Filling missing fields
126
+ * with zero would understate real spend, which is a silent misreport.
127
+ */
128
+ function readUsage(value: unknown): Usage | undefined {
129
+ if (typeof value !== 'object' || value === null) return undefined;
130
+ const cost: unknown = Reflect.get(value, 'cost');
131
+ if (typeof cost !== 'object' || cost === null) return undefined;
132
+ const input = finiteNonNegative(value, 'input');
133
+ const output = finiteNonNegative(value, 'output');
134
+ const cacheRead = finiteNonNegative(value, 'cacheRead');
135
+ const cacheWrite = finiteNonNegative(value, 'cacheWrite');
136
+ const totalTokens = finiteNonNegative(value, 'totalTokens');
137
+ const costInput = finiteNonNegative(cost, 'input');
138
+ const costOutput = finiteNonNegative(cost, 'output');
139
+ const costCacheRead = finiteNonNegative(cost, 'cacheRead');
140
+ const costCacheWrite = finiteNonNegative(cost, 'cacheWrite');
141
+ const costTotal = finiteNonNegative(cost, 'total');
142
+ if (
143
+ input === undefined ||
144
+ output === undefined ||
145
+ cacheRead === undefined ||
146
+ cacheWrite === undefined ||
147
+ totalTokens === undefined ||
148
+ costInput === undefined ||
149
+ costOutput === undefined ||
150
+ costCacheRead === undefined ||
151
+ costCacheWrite === undefined ||
152
+ costTotal === undefined
153
+ ) {
154
+ return undefined;
155
+ }
156
+ return {
157
+ input,
158
+ output,
159
+ cacheRead,
160
+ cacheWrite,
161
+ totalTokens,
162
+ cost: {
163
+ input: costInput,
164
+ output: costOutput,
165
+ cacheRead: costCacheRead,
166
+ cacheWrite: costCacheWrite,
167
+ total: costTotal,
168
+ },
169
+ };
170
+ }
171
+
172
+ function readEnv(key: string): string {
173
+ const value = process.env[key];
174
+ if (value === undefined || value.length === 0) {
175
+ throw new Error(`delegate child requires ${key}`);
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function pathInside(parent: string, child: string): boolean {
181
+ const rel = relative(parent, child);
182
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
183
+ }
184
+
185
+ /** Synchronous durable commit: temp write, fsync, rename, directory fsync. */
186
+ function commitFileSync(absPath: string, data: Buffer): void {
187
+ const dir = dirname(absPath);
188
+ mkdirSync(dir, { recursive: true });
189
+ const temporary = `${absPath}.${String(process.pid)}.${Date.now().toString(36)}.tmp`;
190
+ const handle = openSync(temporary, 'wx', 0o600);
191
+ try {
192
+ let written = 0;
193
+ while (written < data.length) {
194
+ written += writeSync(handle, data, written, data.length - written, null);
195
+ }
196
+ fsyncSync(handle);
197
+ } finally {
198
+ closeSync(handle);
199
+ }
200
+ renameSync(temporary, absPath);
201
+ if (process.platform !== 'win32') {
202
+ const dirHandle = openSync(dir, 'r');
203
+ try {
204
+ fsyncSync(dirHandle);
205
+ } finally {
206
+ closeSync(dirHandle);
207
+ }
208
+ }
209
+ }
210
+
211
+ interface ContentMeasurement {
212
+ bytes: number;
213
+ multibyteBytes: number;
214
+ denseBytes: 0;
215
+ }
216
+
217
+ function emptyContentMeasurement(): ContentMeasurement {
218
+ return { bytes: 0, multibyteBytes: 0, denseBytes: 0 };
219
+ }
220
+
221
+ function addContentMeasurement(target: ContentMeasurement, delta: ContentMeasurement): void {
222
+ target.bytes += delta.bytes;
223
+ target.multibyteBytes += delta.multibyteBytes;
224
+ }
225
+
226
+ function stringMeasurement(value: string): ContentMeasurement {
227
+ return utf8ByteClassBreakdown(value);
228
+ }
229
+
230
+ function messageContentMeasurement(content: unknown): ContentMeasurement {
231
+ if (typeof content === 'string') return stringMeasurement(content);
232
+ const total = emptyContentMeasurement();
233
+ if (!Array.isArray(content)) return total;
234
+ for (const part of content) {
235
+ if (typeof part !== 'object' || part === null) continue;
236
+ const text: unknown = Reflect.get(part, 'text');
237
+ if (typeof text === 'string') addContentMeasurement(total, stringMeasurement(text));
238
+ const thinkingText: unknown = Reflect.get(part, 'thinking');
239
+ if (typeof thinkingText === 'string') addContentMeasurement(total, stringMeasurement(thinkingText));
240
+ const args: unknown = Reflect.get(part, 'arguments');
241
+ if (args !== undefined) addContentMeasurement(total, stringMeasurement(JSON.stringify(args) ?? ''));
242
+ const data: unknown = Reflect.get(part, 'data');
243
+ if (typeof data === 'string') addContentMeasurement(total, stringMeasurement(data));
244
+ }
245
+ return total;
246
+ }
247
+
248
+ /** Complete retained input measured the same way the admission plan measured the seed. */
249
+ function retainedInputMeasurement(messages: readonly object[], systemPrompt: string): ContentMeasurement {
250
+ const total = stringMeasurement(systemPrompt);
251
+ for (const message of messages) addContentMeasurement(total, messageContentMeasurement(Reflect.get(message, 'content')));
252
+ return total;
253
+ }
254
+
255
+ const ArtifactReadParams = Type.Object(
256
+ {
257
+ artifact: Type.String({
258
+ description:
259
+ 'Relative artifact path exactly as named in a spill receipt, for example spill/t0001-c0000-abc.bin',
260
+ }),
261
+ offset: Type.Number({ description: 'Byte offset to start reading from. Zero-based.' }),
262
+ length: Type.Number({ description: 'Exact number of bytes to read. Must be positive.' }),
263
+ },
264
+ { additionalProperties: false },
265
+ );
266
+
267
+ type ArtifactReadParamsValue = Static<typeof ArtifactReadParams>;
268
+
269
+ interface ArtifactReadDetails {
270
+ artifact: string;
271
+ offset: number;
272
+ length: number;
273
+ total_bytes: number;
274
+ }
275
+
276
+ export default function delegateChildExtension(pi: ExtensionAPI): void {
277
+ const artifactDirAbs = readEnv('PI_BG_DELEGATE_ARTIFACT_DIR');
278
+ const seedPath = readEnv('PI_BG_DELEGATE_SEED_PATH');
279
+ const expectedSeedSha = readEnv('PI_BG_DELEGATE_SEED_SHA256');
280
+ const expectedTaskId = readEnv('PI_BG_DELEGATE_TASK_ID');
281
+ const expectedNonce = readEnv('PI_BG_DELEGATE_LAUNCH_NONCE');
282
+
283
+ // Seed verification happens at load, before the first model call. A seed
284
+ // that does not match its declared hash and identity aborts the child rather
285
+ // than running with content the parent did not author.
286
+ const seedRaw = readFileSync(seedPath, 'utf8');
287
+ const seed = verifyDelegateSeedBytes(seedRaw, {
288
+ sha256: expectedSeedSha,
289
+ taskId: expectedTaskId,
290
+ launchNonce: expectedNonce,
291
+ });
292
+
293
+ const state: GuardState = {
294
+ seed,
295
+ artifactDirAbs,
296
+ turns: 0,
297
+ toolCalls: 0,
298
+ totalToolOutputBytes: 0,
299
+ spilled: [],
300
+ attestations: [],
301
+ usage: undefined,
302
+ usageUnavailableReason: 'the child produced no assistant message carrying usage',
303
+ answerBlocks: [],
304
+ terminal: undefined,
305
+ committed: false,
306
+ };
307
+
308
+ function latch(code: string, message: string): void {
309
+ if (state.terminal === undefined) state.terminal = { code, message };
310
+ }
311
+
312
+ function usageReport(): DelegateUsageReport {
313
+ if (state.usage !== undefined) return { status: 'observed', usage: state.usage };
314
+ return {
315
+ status: 'unavailable',
316
+ reason: state.usageUnavailableReason ?? 'usage was not reported by the provider',
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Commit exactly one result package.
322
+ *
323
+ * Refuses to commit when a terminal condition has latched, so a degraded run
324
+ * can never be reported as a clean success. Refuses to commit twice.
325
+ */
326
+ function commitResult(stopReason: string): void {
327
+ if (state.committed) return;
328
+ if (state.terminal !== undefined) {
329
+ writeTerminalRecord(state.terminal);
330
+ return;
331
+ }
332
+ if (state.answerBlocks.length === 0) {
333
+ writeTerminalRecord({
334
+ code: 'child_exited_without_commit',
335
+ message: 'the delegate child produced no assistant answer text',
336
+ });
337
+ return;
338
+ }
339
+ // A hash proves the bytes are intact; it cannot prove they are complete.
340
+ // Only an approved terminal stop reason may be committed as success, so a
341
+ // response cut short by the output-token limit, a content filter, an
342
+ // aborted run, or a provider error can never be returned as a whole answer.
343
+ if (!ACCEPTED_STOP_REASONS.has(stopReason)) {
344
+ writeTerminalRecord({
345
+ code: stopReason === 'length' ? 'child_model_output_limit' : 'child_result_invalid',
346
+ message: `the delegate child stopped with reason "${stopReason}", so its answer is incomplete and is not committed as a result; the captured text is preserved in the child transcript`,
347
+ });
348
+ return;
349
+ }
350
+ if (state.answerBlocks.join('').trim().length === 0) {
351
+ writeTerminalRecord({
352
+ code: 'child_result_invalid',
353
+ message: 'the delegate child produced only whitespace, which is not a usable answer',
354
+ });
355
+ return;
356
+ }
357
+ const pkg = buildDelegateResultPackage({
358
+ taskId: seed.task_id,
359
+ launchNonce: seed.launch_nonce,
360
+ seedSha256: expectedSeedSha,
361
+ directiveSha256: seed.directive.sha256,
362
+ route: { provider: seed.route.provider, model: seed.route.model },
363
+ routeAttestations: state.attestations,
364
+ stopReason,
365
+ turns: state.turns,
366
+ toolCalls: state.toolCalls,
367
+ usage: usageReport(),
368
+ answerBlocks: state.answerBlocks,
369
+ spilledArtifacts: state.spilled,
370
+ });
371
+ commitFileSync(join(artifactDirAbs, 'result.json'), utf8(serializeDelegateResultPackage(pkg)));
372
+ state.committed = true;
373
+ }
374
+
375
+ function writeTerminalRecord(terminal: TerminalLatch): void {
376
+ commitFileSync(
377
+ join(artifactDirAbs, 'child-terminal.json'),
378
+ utf8(
379
+ `${JSON.stringify(
380
+ {
381
+ schema_version: 'pi-background-tasks.delegate-child-terminal.v1',
382
+ task_id: seed.task_id,
383
+ launch_nonce: seed.launch_nonce,
384
+ code: terminal.code,
385
+ message: terminal.message,
386
+ turns: state.turns,
387
+ tool_calls: state.toolCalls,
388
+ spilled_artifacts: state.spilled,
389
+ },
390
+ null,
391
+ 2,
392
+ )}\n`,
393
+ ),
394
+ );
395
+ }
396
+
397
+ pi.registerTool<typeof ArtifactReadParams, ArtifactReadDetails>({
398
+ name: 'delegate_read_artifact',
399
+ label: 'Delegate Artifact Read',
400
+ description:
401
+ 'Read an exact byte range from a spilled tool-result artifact. Returns exactly the requested range or fails; it never returns fewer bytes than requested and never clamps the request.',
402
+ promptSnippet: 'Read an exact byte range from a spilled tool-result artifact',
403
+ promptGuidelines: [
404
+ 'Use delegate_read_artifact when a tool result was replaced by a spill receipt and the omitted bytes are actually needed.',
405
+ 'Request a bounded range. A request past the end of the artifact fails loudly rather than returning a short read.',
406
+ ],
407
+ parameters: ArtifactReadParams,
408
+ prepareArguments(args): ArtifactReadParamsValue {
409
+ if (typeof args !== 'object' || args === null)
410
+ throw new Error('delegate_read_artifact arguments must be an object');
411
+ const artifact: unknown = Reflect.get(args, 'artifact');
412
+ const offset: unknown = Reflect.get(args, 'offset');
413
+ const length: unknown = Reflect.get(args, 'length');
414
+ if (typeof artifact !== 'string')
415
+ throw new Error('delegate_read_artifact requires artifact string');
416
+ if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
417
+ throw new Error('delegate_read_artifact requires a non-negative integer offset');
418
+ if (typeof length !== 'number' || !Number.isSafeInteger(length) || length <= 0)
419
+ throw new Error('delegate_read_artifact requires a positive integer length');
420
+ return { artifact, offset, length };
421
+ },
422
+ execute(_toolCallId, params) {
423
+ const absPath = join(artifactDirAbs, params.artifact);
424
+ if (!pathInside(artifactDirAbs, absPath)) {
425
+ throw new Error(
426
+ `delegate_read_artifact path ${params.artifact} escapes the delegate artifact directory`,
427
+ );
428
+ }
429
+ const bytes = readFileSync(absPath);
430
+ const end = params.offset + params.length;
431
+ if (end > bytes.length) {
432
+ throw new Error(
433
+ `delegate_read_artifact requested bytes ${String(params.offset)}..${String(end)} but ${params.artifact} is ${String(bytes.length)} bytes; the read is refused rather than silently shortened`,
434
+ );
435
+ }
436
+ const slice = bytes.subarray(params.offset, end);
437
+ if (slice.length !== params.length) {
438
+ throw new Error(
439
+ `delegate_read_artifact returned ${String(slice.length)} of ${String(params.length)} requested bytes`,
440
+ );
441
+ }
442
+ return Promise.resolve({
443
+ content: [{ type: 'text' as const, text: slice.toString('utf8') }],
444
+ details: {
445
+ artifact: params.artifact,
446
+ offset: params.offset,
447
+ length: params.length,
448
+ total_bytes: bytes.length,
449
+ },
450
+ });
451
+ },
452
+ });
453
+
454
+ pi.on('context', (event, ctx) => {
455
+ // Fail closed. Pi swallows exceptions thrown from a `context` handler and
456
+ // dispatches the call regardless, so a throw inside this guard would let the
457
+ // ORIGINAL unguarded message set reach the provider. Every path therefore
458
+ // runs inside this try, and the catch latches terminal state and suppresses
459
+ // the content rather than letting the original through.
460
+ try {
461
+ const measurement = retainedInputMeasurement(event.messages, ctx.getSystemPrompt());
462
+ const verdict = evaluateDelegateRuntimeBudget(
463
+ {
464
+ retainedInputBytes: measurement.bytes,
465
+ retainedInputMultibyteBytes: measurement.multibyteBytes,
466
+ retainedInputDenseBytes: measurement.denseBytes,
467
+ },
468
+ seed.limits.allowed_input_tokens,
469
+ seed.route,
470
+ );
471
+ if (verdict.withinBudget) return undefined;
472
+ const message = `delegate child context reached ${String(verdict.measuredTokens)} input tokens against a ${String(verdict.allowedTokens)}-token allowance on route ${seed.route.qualified_id}, over by ${String(verdict.overageTokens)}; estimator family ${verdict.rateSource.family}, source ${verdict.rateSource.source}, backed=${String(verdict.backed)}, dominant_byte_class=${verdict.dominantByteClass}, rate ${String(verdict.rateSource.effective_rate_bytes_per_token_x100)}/100 B/tok + ${String(verdict.rateSource.affine_f_tokens)} tokens`;
473
+ latch('provider_context_budget_exhausted', message);
474
+ // Barrier one: terminate the run. Measured on Pi 0.83, this hands the
475
+ // provider call an already-aborted signal and stops further turns.
476
+ ctx.abort();
477
+ // Barrier two: remove the content itself, so the request could not carry it
478
+ // even if a provider ignored the aborted signal. Retaining only the first
479
+ // message keeps the shape valid without transmitting the oversized tail.
480
+ return { messages: suppressedMessages(event.messages) };
481
+ } catch (error) {
482
+ latch(
483
+ 'child_result_invalid',
484
+ `delegate context guard failed and the run was stopped rather than dispatched unguarded: ${error instanceof Error ? error.message : String(error)}`,
485
+ );
486
+ try {
487
+ ctx.abort();
488
+ } catch {
489
+ // An abort failure must not resurrect the unguarded message set. The
490
+ // latch above already prevents a success commit, and the suppressed
491
+ // replacement below still removes the content from this request.
492
+ }
493
+ return { messages: suppressedMessages(event.messages) };
494
+ }
495
+ });
496
+
497
+ pi.on('tool_result', (event) => {
498
+ // Fail closed for the same reason as the context guard: a throw here would
499
+ // let the ORIGINAL oversized payload flow into the transcript.
500
+ try {
501
+ return guardToolResult(event);
502
+ } catch (error) {
503
+ latch(
504
+ 'artifact_spill_failed',
505
+ `delegate tool-result guard failed and the payload was withheld: ${error instanceof Error ? error.message : String(error)}`,
506
+ );
507
+ return {
508
+ content: [
509
+ {
510
+ type: 'text' as const,
511
+ text: '[delegate: tool result withheld because the result guard failed; the run is terminating]',
512
+ },
513
+ ],
514
+ isError: true,
515
+ };
516
+ }
517
+ });
518
+
519
+ function guardToolResult(event: {
520
+ toolName: string;
521
+ toolCallId: string;
522
+ content: ReadonlyArray<{ type: string; text?: string }>;
523
+ }): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } | undefined {
524
+ state.toolCalls += 1;
525
+ if (state.toolCalls > seed.limits.max_tool_calls) {
526
+ latch(
527
+ 'child_tool_call_limit',
528
+ `delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
529
+ );
530
+ }
531
+ const texts = event.content.flatMap((part) =>
532
+ part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
533
+ );
534
+ const joined = texts.join('');
535
+ const payload = utf8(joined);
536
+ state.totalToolOutputBytes += payload.length;
537
+ if (state.totalToolOutputBytes > seed.limits.max_total_tool_output_bytes) {
538
+ latch(
539
+ 'aggregate_tool_output_cap',
540
+ `delegate child accumulated ${String(state.totalToolOutputBytes)} bytes of tool output, exceeding its ${String(seed.limits.max_total_tool_output_bytes)}-byte cap`,
541
+ );
542
+ }
543
+ if (payload.length <= seed.limits.max_tool_result_bytes) return undefined;
544
+
545
+ // Oversized: spill to a hashed artifact and replace the transcript content
546
+ // with an explicit receipt. The raw payload never enters the transcript.
547
+ const turnSequence = state.turns;
548
+ const sourceCallIndex = state.spilled.length;
549
+ const safeCallId = event.toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 64);
550
+ const name = `t${String(turnSequence).padStart(4, '0')}-c${String(sourceCallIndex).padStart(4, '0')}-${safeCallId}.bin`;
551
+ const relPath = join(SPILL_DIRNAME, name);
552
+ const absPath = join(artifactDirAbs, relPath);
553
+ if (!pathInside(artifactDirAbs, absPath)) {
554
+ latch('artifact_spill_failed', `delegate spill path escapes the artifact directory: ${name}`);
555
+ return {
556
+ content: [
557
+ {
558
+ type: 'text' as const,
559
+ text: '[delegate: tool result withheld because its spill path was rejected]',
560
+ },
561
+ ],
562
+ };
563
+ }
564
+ try {
565
+ commitFileSync(absPath, payload);
566
+ } catch (error) {
567
+ // A spill that cannot be committed is terminal. The original payload is
568
+ // never returned as a fallback, and no receipt claims an uncommitted file.
569
+ latch(
570
+ 'artifact_spill_failed',
571
+ `delegate could not spill a ${String(payload.length)}-byte tool result: ${error instanceof Error ? error.message : String(error)}`,
572
+ );
573
+ return {
574
+ content: [
575
+ {
576
+ type: 'text' as const,
577
+ text: '[delegate: tool result withheld because it could not be durably spilled; the run is terminating]',
578
+ },
579
+ ],
580
+ isError: true,
581
+ };
582
+ }
583
+ const receipt: DelegateSpillReceipt = {
584
+ schema_version: DELEGATE_RECEIPT_SCHEMA_VERSION,
585
+ artifact: relPath,
586
+ tool_name: event.toolName,
587
+ tool_call_id: event.toolCallId,
588
+ turn_sequence: turnSequence,
589
+ source_call_index: sourceCallIndex,
590
+ byte_length: payload.length,
591
+ sha256: sha256(payload),
592
+ };
593
+ state.spilled.push(receipt);
594
+ return {
595
+ content: [
596
+ {
597
+ type: 'text' as const,
598
+ text: [
599
+ `[delegate spill receipt] The ${event.toolName} result was ${String(payload.length)} bytes, over the ${String(seed.limits.max_tool_result_bytes)}-byte transcript cap.`,
600
+ `It was written in full to ${relPath} (sha256 ${receipt.sha256}).`,
601
+ 'Nothing was truncated: the complete bytes are on disk.',
602
+ `Read an exact range with delegate_read_artifact({artifact:"${relPath}", offset, length}).`,
603
+ ].join('\n'),
604
+ },
605
+ ],
606
+ };
607
+ }
608
+
609
+ pi.on('turn_start', () => {
610
+ state.turns += 1;
611
+ if (state.turns > seed.limits.max_turns) {
612
+ latch(
613
+ 'child_turn_limit',
614
+ `delegate child exceeded its ${String(seed.limits.max_turns)} turn limit`,
615
+ );
616
+ }
617
+ });
618
+
619
+ pi.on('message_end', (event) => {
620
+ if (event.message.role !== 'assistant') return;
621
+ const provider: unknown = Reflect.get(event.message, 'provider');
622
+ const model: unknown = Reflect.get(event.message, 'model');
623
+ const stopReason: unknown = Reflect.get(event.message, 'stopReason');
624
+ const attestation: DelegateRouteAttestation = {
625
+ provider: typeof provider === 'string' ? provider : '',
626
+ model: typeof model === 'string' ? model : '',
627
+ stop_reason: typeof stopReason === 'string' ? stopReason : '',
628
+ };
629
+ state.attestations.push(attestation);
630
+ if (
631
+ attestation.provider !== seed.route.provider ||
632
+ attestation.model !== seed.route.model
633
+ ) {
634
+ latch(
635
+ 'route_mismatch',
636
+ `delegate child produced an assistant message on ${attestation.provider}/${attestation.model}, but the pinned route is ${seed.route.qualified_id}`,
637
+ );
638
+ }
639
+ const observedUsage = readUsage(Reflect.get(event.message, 'usage'));
640
+ if (observedUsage === undefined) {
641
+ // Never synthesize zero usage. An absent or incomplete usage record stays
642
+ // explicitly unavailable so the parent cannot report a free run.
643
+ state.usageUnavailableReason =
644
+ 'the provider did not report a complete token/cost usage record';
645
+ } else {
646
+ state.usage = observedUsage;
647
+ state.usageUnavailableReason = undefined;
648
+ }
649
+ const content: unknown = Reflect.get(event.message, 'content');
650
+ if (!Array.isArray(content)) return;
651
+ for (const part of content) {
652
+ if (typeof part !== 'object' || part === null) continue;
653
+ if (Reflect.get(part, 'type') !== 'text') continue;
654
+ const text: unknown = Reflect.get(part, 'text');
655
+ if (typeof text === 'string' && text.length > 0) state.answerBlocks.push(text);
656
+ }
657
+ });
658
+
659
+ pi.on('agent_end', () => {
660
+ const finalStop = state.attestations.at(-1)?.stop_reason ?? 'unknown';
661
+ commitResult(finalStop);
662
+ });
663
+
664
+ pi.on('session_shutdown', () => {
665
+ // A shutdown before agent_end means no answer was produced. Record it so the
666
+ // parent sees a typed reason instead of an empty directory.
667
+ if (state.committed) return;
668
+ if (state.terminal === undefined) {
669
+ latch('child_exited_without_commit', 'the delegate child shut down before committing a result');
670
+ }
671
+ if (state.terminal !== undefined) writeTerminalRecord(state.terminal);
672
+ });
673
+ }