@pi-unipi/background-tasks 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,978 @@
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
+ DELEGATE_CAPABILITIES,
11
+ type DelegateRouteAttestation,
12
+ type DelegateSeedV1,
13
+ type DelegateSpillReceipt,
14
+ type DelegateUsageReport,
15
+ } from './delegate/types.js';
16
+ import { verifyDelegateSeedBytes } from './delegate/seed.js';
17
+ import {
18
+ DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
19
+ DELEGATE_FINALIZATION_TRIGGER_TOKENS,
20
+ evaluateDelegateRuntimeBudget,
21
+ } from './delegate/budget.js';
22
+ import { utf8ByteClassBreakdown } from './context-token-budget.js';
23
+ import {
24
+ assertWellFormedUtf8,
25
+ buildDelegateResultPackage,
26
+ serializeDelegateResultPackage,
27
+ } from './delegate/result-package.js';
28
+
29
+ /**
30
+ * Package-owned delegate child extension.
31
+ *
32
+ * This runs inside every delegate child Pi process and is the package-owned
33
+ * child guard. Anthropic routes load attribution first, and ambient mode may
34
+ * also execute discovered extensions; this guard remains
35
+ * responsible for every isolation guarantee that cannot be enforced from the
36
+ * parent:
37
+ *
38
+ * - verifying the frozen seed bytes before the first model call;
39
+ * - measuring the outgoing message set before every model call and requesting
40
+ * no-tool finalization when advisory runway becomes low;
41
+ * - spilling oversized or runway-pressuring tool results to hashed artifacts
42
+ * and replacing them with explicit receipts before transcript entry;
43
+ * - asserting every assistant message came from the pinned route;
44
+ * - enforcing turn and tool-call limits;
45
+ * - committing exactly one result package atomically.
46
+ *
47
+ * Measured behavior across the supported Pi lines (see
48
+ * `tests/scripted-provider/pi-hook-contract.test.ts` and `scripts/test-compat.ts`):
49
+ *
50
+ * - Throwing from a `context` handler does NOT stop dispatch. Pi catches the
51
+ * exception and continues, so a throw is never used as a barrier here.
52
+ * - `ctx.abort()` blocks transport in both supported modes: Pi 0.81.1-0.83.0
53
+ * invoke the provider with an already-aborted signal, while Pi 0.84.0 skips
54
+ * the provider entry point during signal-aware auth resolution.
55
+ * - The guard ALSO replaces the offending content in the returned message set.
56
+ * Even a non-conforming provider could not transmit the content because the
57
+ * content is no longer there.
58
+ */
59
+
60
+ const SPILL_DIRNAME = 'spill';
61
+
62
+ interface GuardState {
63
+ seed: DelegateSeedV1;
64
+ artifactDirAbs: string;
65
+ turns: number;
66
+ toolCalls: number;
67
+ totalToolOutputBytes: number;
68
+ spilled: DelegateSpillReceipt[];
69
+ attestations: DelegateRouteAttestation[];
70
+ usage: Usage | undefined;
71
+ usageIncomplete: boolean;
72
+ usageUnavailableReason: string | undefined;
73
+ answerBlocks: string[];
74
+ answerBytes: number;
75
+ retainedGrowthTokens: number;
76
+ retainedGrowthBudgetTokens: number | undefined;
77
+ retainedToolResultBytes: number;
78
+ contextPressureSpillBytes: number;
79
+ finalizationRequested: boolean;
80
+ finalizationReason: string | undefined;
81
+ contextMeasurements: RuntimeContextMeasurement[];
82
+ firstRequestObservedInputTokens: number | undefined;
83
+ runtimeBudgetWritten: boolean;
84
+ terminal: TerminalLatch | undefined;
85
+ committed: boolean;
86
+ }
87
+
88
+ /**
89
+ * A terminal condition latches.
90
+ *
91
+ * Once the guard has degraded or refused anything, no later assistant message
92
+ * may be committed as a successful answer. Otherwise a run whose context was
93
+ * silently mutilated could still produce a hash-valid package, which is exactly
94
+ * the "hash-valid but wrong" failure this design must not have.
95
+ */
96
+ interface TerminalLatch {
97
+ code: string;
98
+ message: string;
99
+ }
100
+
101
+ interface RuntimeContextMeasurement {
102
+ request_ordinal: number;
103
+ retained_utf8_bytes: number;
104
+ estimated_input_tokens: number;
105
+ allowed_input_tokens: number;
106
+ signed_headroom_tokens: number;
107
+ dominant_byte_class: string;
108
+ finalization_requested: boolean;
109
+ }
110
+
111
+ /**
112
+ * Stop reasons that may be committed as a complete answer.
113
+ *
114
+ * `length` means the provider truncated the response at the output-token limit,
115
+ * `aborted` and `error` mean the run did not finish, and a pending tool call
116
+ * means the agent had more to do. None of those is a whole answer, so none of
117
+ * them may be committed as one.
118
+ */
119
+ const ACCEPTED_STOP_REASONS: ReadonlySet<string> = new Set(['stop']);
120
+
121
+ function sha256(value: Buffer): string {
122
+ return createHash('sha256').update(value).digest('hex');
123
+ }
124
+
125
+ /**
126
+ * Replacement message set used when the guard suppresses a request.
127
+ *
128
+ * Keeps the shape valid without transmitting the content that triggered the
129
+ * suppression. The head message is retained only when it is a user message, so
130
+ * a suppressed request cannot carry assistant or tool content forward.
131
+ */
132
+ function suppressedMessages<TMessage extends object>(
133
+ messages: readonly TMessage[],
134
+ ): TMessage[] {
135
+ const head = messages[0];
136
+ if (head === undefined) return [];
137
+ return Reflect.get(head, 'role') === 'user' ? [head] : [];
138
+ }
139
+
140
+ function utf8(value: string): Buffer {
141
+ return Buffer.from(value, 'utf8');
142
+ }
143
+
144
+ interface DelegateToolTextPart {
145
+ readonly type: 'text';
146
+ readonly text: string;
147
+ }
148
+
149
+ interface DelegateToolImagePart {
150
+ readonly type: 'image';
151
+ readonly data: string;
152
+ readonly mimeType: string;
153
+ }
154
+
155
+ type DelegateToolResultPart = DelegateToolTextPart | DelegateToolImagePart;
156
+ type SpillContentFormat = DelegateSpillReceipt['content_format'];
157
+
158
+ function encodeToolResultContent(
159
+ content: ReadonlyArray<DelegateToolResultPart>,
160
+ ): { payload: Buffer; contentFormat: Exclude<SpillContentFormat, undefined> } {
161
+ if (content.length === 1) {
162
+ const only = content[0];
163
+ if (only?.type === 'text') {
164
+ return {
165
+ payload: assertWellFormedUtf8(only.text, 'delegate single-text tool result'),
166
+ contentFormat: 'single_text_utf8',
167
+ };
168
+ }
169
+ }
170
+ const normalized = content.map((part) => {
171
+ if (part.type === 'text' && typeof part.text === 'string') {
172
+ return { type: 'text' as const, text: part.text };
173
+ }
174
+ if (
175
+ part.type === 'image' &&
176
+ typeof part.data === 'string' &&
177
+ typeof part.mimeType === 'string'
178
+ ) {
179
+ return {
180
+ type: 'image' as const,
181
+ data: part.data,
182
+ mimeType: part.mimeType,
183
+ };
184
+ }
185
+ throw new Error('delegate tool result contains an unsupported or malformed content block');
186
+ });
187
+ return {
188
+ payload: utf8(
189
+ JSON.stringify({
190
+ schema_version: 'pi-background-tasks.delegate-tool-result-content.v1',
191
+ content: normalized,
192
+ }),
193
+ ),
194
+ contentFormat: 'tool_result_content_json_v1',
195
+ };
196
+ }
197
+
198
+ function addUsage(current: Usage | undefined, delta: Usage): Usage {
199
+ const prior = current ?? {
200
+ input: 0,
201
+ output: 0,
202
+ cacheRead: 0,
203
+ cacheWrite: 0,
204
+ totalTokens: 0,
205
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
206
+ };
207
+ return {
208
+ input: prior.input + delta.input,
209
+ output: prior.output + delta.output,
210
+ cacheRead: prior.cacheRead + delta.cacheRead,
211
+ cacheWrite: prior.cacheWrite + delta.cacheWrite,
212
+ totalTokens: prior.totalTokens + delta.totalTokens,
213
+ cost: {
214
+ input: prior.cost.input + delta.cost.input,
215
+ output: prior.cost.output + delta.cost.output,
216
+ cacheRead: prior.cost.cacheRead + delta.cost.cacheRead,
217
+ cacheWrite: prior.cost.cacheWrite + delta.cost.cacheWrite,
218
+ total: prior.cost.total + delta.cost.total,
219
+ },
220
+ };
221
+ }
222
+
223
+ function finiteNonNegative(source: object, key: string): number | undefined {
224
+ const value: unknown = Reflect.get(source, key);
225
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
226
+ return value;
227
+ }
228
+
229
+ /**
230
+ * Read a complete Pi `Usage` record, or report none.
231
+ *
232
+ * A partial usage record is treated as no usage at all. Filling missing fields
233
+ * with zero would understate real spend, which is a silent misreport.
234
+ */
235
+ function readUsage(value: unknown): Usage | undefined {
236
+ if (typeof value !== 'object' || value === null) return undefined;
237
+ const cost: unknown = Reflect.get(value, 'cost');
238
+ if (typeof cost !== 'object' || cost === null) return undefined;
239
+ const input = finiteNonNegative(value, 'input');
240
+ const output = finiteNonNegative(value, 'output');
241
+ const cacheRead = finiteNonNegative(value, 'cacheRead');
242
+ const cacheWrite = finiteNonNegative(value, 'cacheWrite');
243
+ const totalTokens = finiteNonNegative(value, 'totalTokens');
244
+ const costInput = finiteNonNegative(cost, 'input');
245
+ const costOutput = finiteNonNegative(cost, 'output');
246
+ const costCacheRead = finiteNonNegative(cost, 'cacheRead');
247
+ const costCacheWrite = finiteNonNegative(cost, 'cacheWrite');
248
+ const costTotal = finiteNonNegative(cost, 'total');
249
+ if (
250
+ input === undefined ||
251
+ output === undefined ||
252
+ cacheRead === undefined ||
253
+ cacheWrite === undefined ||
254
+ totalTokens === undefined ||
255
+ costInput === undefined ||
256
+ costOutput === undefined ||
257
+ costCacheRead === undefined ||
258
+ costCacheWrite === undefined ||
259
+ costTotal === undefined
260
+ ) {
261
+ return undefined;
262
+ }
263
+ return {
264
+ input,
265
+ output,
266
+ cacheRead,
267
+ cacheWrite,
268
+ totalTokens,
269
+ cost: {
270
+ input: costInput,
271
+ output: costOutput,
272
+ cacheRead: costCacheRead,
273
+ cacheWrite: costCacheWrite,
274
+ total: costTotal,
275
+ },
276
+ };
277
+ }
278
+
279
+ function readEnv(key: string): string {
280
+ const value = process.env[key];
281
+ if (value === undefined || value.length === 0) {
282
+ throw new Error(`delegate child requires ${key}`);
283
+ }
284
+ return value;
285
+ }
286
+
287
+ function pathInside(parent: string, child: string): boolean {
288
+ const rel = relative(parent, child);
289
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
290
+ }
291
+
292
+ /** Synchronous durable commit: temp write, fsync, rename, directory fsync. */
293
+ function commitFileSync(absPath: string, data: Buffer): void {
294
+ const dir = dirname(absPath);
295
+ mkdirSync(dir, { recursive: true });
296
+ const temporary = `${absPath}.${String(process.pid)}.${Date.now().toString(36)}.tmp`;
297
+ const handle = openSync(temporary, 'wx', 0o600);
298
+ try {
299
+ let written = 0;
300
+ while (written < data.length) {
301
+ written += writeSync(handle, data, written, data.length - written, null);
302
+ }
303
+ fsyncSync(handle);
304
+ } finally {
305
+ closeSync(handle);
306
+ }
307
+ renameSync(temporary, absPath);
308
+ if (process.platform !== 'win32') {
309
+ const dirHandle = openSync(dir, 'r');
310
+ try {
311
+ fsyncSync(dirHandle);
312
+ } finally {
313
+ closeSync(dirHandle);
314
+ }
315
+ }
316
+ }
317
+
318
+ interface ContentMeasurement {
319
+ bytes: number;
320
+ multibyteBytes: number;
321
+ denseBytes: 0;
322
+ }
323
+
324
+ function emptyContentMeasurement(): ContentMeasurement {
325
+ return { bytes: 0, multibyteBytes: 0, denseBytes: 0 };
326
+ }
327
+
328
+ function addContentMeasurement(target: ContentMeasurement, delta: ContentMeasurement): void {
329
+ target.bytes += delta.bytes;
330
+ target.multibyteBytes += delta.multibyteBytes;
331
+ }
332
+
333
+ function stringMeasurement(value: string): ContentMeasurement {
334
+ return utf8ByteClassBreakdown(value);
335
+ }
336
+
337
+ function messageContentMeasurement(content: unknown): ContentMeasurement {
338
+ if (typeof content === 'string') return stringMeasurement(content);
339
+ const total = emptyContentMeasurement();
340
+ if (!Array.isArray(content)) return total;
341
+ for (const part of content) {
342
+ if (typeof part !== 'object' || part === null) continue;
343
+ const text: unknown = Reflect.get(part, 'text');
344
+ if (typeof text === 'string') addContentMeasurement(total, stringMeasurement(text));
345
+ const thinkingText: unknown = Reflect.get(part, 'thinking');
346
+ if (typeof thinkingText === 'string') addContentMeasurement(total, stringMeasurement(thinkingText));
347
+ const args: unknown = Reflect.get(part, 'arguments');
348
+ if (args !== undefined) addContentMeasurement(total, stringMeasurement(JSON.stringify(args) ?? ''));
349
+ const data: unknown = Reflect.get(part, 'data');
350
+ if (typeof data === 'string') addContentMeasurement(total, stringMeasurement(data));
351
+ }
352
+ return total;
353
+ }
354
+
355
+ /** Complete retained input measured the same way the admission plan measured the seed. */
356
+ function retainedInputMeasurement(messages: readonly object[], systemPrompt: string): ContentMeasurement {
357
+ const total = stringMeasurement(systemPrompt);
358
+ for (const message of messages) addContentMeasurement(total, messageContentMeasurement(Reflect.get(message, 'content')));
359
+ return total;
360
+ }
361
+
362
+ const ArtifactReadParams = Type.Object(
363
+ {
364
+ artifact: Type.String({
365
+ description:
366
+ 'Relative artifact path exactly as named in a spill receipt, for example spill/t0001-c0000-abc.bin',
367
+ }),
368
+ offset: Type.Number({ description: 'Byte offset to start reading from. Zero-based.' }),
369
+ length: Type.Number({ description: 'Exact number of bytes to read. Must be positive.' }),
370
+ },
371
+ { additionalProperties: false },
372
+ );
373
+
374
+ type ArtifactReadParamsValue = Static<typeof ArtifactReadParams>;
375
+
376
+ interface ArtifactReadDetails {
377
+ artifact: string;
378
+ offset: number;
379
+ length: number;
380
+ total_bytes: number;
381
+ }
382
+
383
+ export default function delegateChildExtension(pi: ExtensionAPI): void {
384
+ const artifactDirAbs = readEnv('UNIPI_BG_DELEGATE_ARTIFACT_DIR');
385
+ const seedPath = readEnv('UNIPI_BG_DELEGATE_SEED_PATH');
386
+ const expectedSeedSha = readEnv('UNIPI_BG_DELEGATE_SEED_SHA256');
387
+ const expectedTaskId = readEnv('UNIPI_BG_DELEGATE_TASK_ID');
388
+ const expectedNonce = readEnv('UNIPI_BG_DELEGATE_LAUNCH_NONCE');
389
+
390
+ // Seed verification happens at load, before the first model call. A seed
391
+ // that does not match its declared hash and identity aborts the child rather
392
+ // than running with content the parent did not author.
393
+ const seedRaw = readFileSync(seedPath, 'utf8');
394
+ const seed = verifyDelegateSeedBytes(seedRaw, {
395
+ sha256: expectedSeedSha,
396
+ taskId: expectedTaskId,
397
+ launchNonce: expectedNonce,
398
+ });
399
+
400
+ if (!DELEGATE_CAPABILITIES.includes(seed.capability)) {
401
+ throw new Error(`delegate child cannot enforce capability ${seed.capability}`);
402
+ }
403
+
404
+ const state: GuardState = {
405
+ seed,
406
+ artifactDirAbs,
407
+ turns: 0,
408
+ toolCalls: 0,
409
+ totalToolOutputBytes: 0,
410
+ spilled: [],
411
+ attestations: [],
412
+ usage: undefined,
413
+ usageIncomplete: false,
414
+ usageUnavailableReason: 'the child produced no assistant message carrying usage',
415
+ answerBlocks: [],
416
+ answerBytes: 0,
417
+ retainedGrowthTokens: 0,
418
+ retainedGrowthBudgetTokens: undefined,
419
+ retainedToolResultBytes: 0,
420
+ contextPressureSpillBytes: 0,
421
+ finalizationRequested: false,
422
+ finalizationReason: undefined,
423
+ contextMeasurements: [],
424
+ firstRequestObservedInputTokens: undefined,
425
+ runtimeBudgetWritten: false,
426
+ terminal: undefined,
427
+ committed: false,
428
+ };
429
+
430
+ function latch(code: string, message: string): void {
431
+ if (state.terminal === undefined) state.terminal = { code, message };
432
+ }
433
+
434
+ function usageReport(): DelegateUsageReport {
435
+ if (!state.usageIncomplete && state.usage !== undefined) {
436
+ return { status: 'observed', usage: state.usage };
437
+ }
438
+ return {
439
+ status: 'unavailable',
440
+ reason: state.usageUnavailableReason ?? 'usage was not reported by the provider',
441
+ };
442
+ }
443
+
444
+ function remainingGrowthTokens(): number {
445
+ if (state.retainedGrowthBudgetTokens === undefined) return 0;
446
+ return Math.max(0, state.retainedGrowthBudgetTokens - state.retainedGrowthTokens);
447
+ }
448
+
449
+ function requestFinalization(reason: string): void {
450
+ if (!state.finalizationRequested) {
451
+ state.finalizationRequested = true;
452
+ state.finalizationReason = reason;
453
+ }
454
+ pi.setActiveTools([]);
455
+ }
456
+
457
+ function accountRetainedGrowth(bytes: number, source: string): void {
458
+ state.retainedGrowthTokens += bytes;
459
+ if (
460
+ state.retainedGrowthBudgetTokens !== undefined &&
461
+ remainingGrowthTokens() <= DELEGATE_FINALIZATION_TRIGGER_TOKENS
462
+ ) {
463
+ requestFinalization(
464
+ `${source} left ${String(remainingGrowthTokens())} protected retained-growth tokens`,
465
+ );
466
+ }
467
+ }
468
+
469
+ function writeRuntimeBudgetRecord(): void {
470
+ if (state.runtimeBudgetWritten) return;
471
+ const latest = state.contextMeasurements.at(-1);
472
+ const firstEstimate = state.contextMeasurements[0]?.estimated_input_tokens;
473
+ const calibrationViolation =
474
+ firstEstimate !== undefined &&
475
+ state.firstRequestObservedInputTokens !== undefined &&
476
+ state.firstRequestObservedInputTokens > firstEstimate
477
+ ? {
478
+ forecast_input_tokens: firstEstimate,
479
+ observed_input_tokens: state.firstRequestObservedInputTokens,
480
+ tokens_under_forecast:
481
+ state.firstRequestObservedInputTokens - firstEstimate,
482
+ }
483
+ : null;
484
+ commitFileSync(
485
+ join(artifactDirAbs, 'runtime-budget.json'),
486
+ utf8(
487
+ `${JSON.stringify(
488
+ {
489
+ schema_version: 'pi-background-tasks.delegate-runtime-budget.v1',
490
+ task_id: seed.task_id,
491
+ launch_nonce: seed.launch_nonce,
492
+ policy: {
493
+ live_provider_context_owner: 'pi_and_provider',
494
+ retained_growth_estimator: 'provable_1_byte_per_token',
495
+ finalization_input_reserve_tokens: DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
496
+ finalization_trigger_tokens: DELEGATE_FINALIZATION_TRIGGER_TOKENS,
497
+ },
498
+ retained_growth_budget_tokens: state.retainedGrowthBudgetTokens ?? null,
499
+ retained_growth_tokens: state.retainedGrowthTokens,
500
+ retained_tool_result_bytes: state.retainedToolResultBytes,
501
+ spilled_tool_result_bytes: state.spilled.reduce(
502
+ (sum, receipt) => sum + receipt.byte_length,
503
+ 0,
504
+ ),
505
+ context_pressure_spill_bytes: state.contextPressureSpillBytes,
506
+ finalization_requested: state.finalizationRequested,
507
+ finalization_reason: state.finalizationReason ?? null,
508
+ first_request_observed_input_tokens: state.firstRequestObservedInputTokens ?? null,
509
+ calibration_violation: calibrationViolation,
510
+ latest_context_estimate: latest ?? null,
511
+ context_measurements: state.contextMeasurements,
512
+ },
513
+ null,
514
+ 2,
515
+ )}\n`,
516
+ ),
517
+ );
518
+ state.runtimeBudgetWritten = true;
519
+ }
520
+
521
+ /**
522
+ * Commit exactly one result package.
523
+ *
524
+ * Refuses to commit when a terminal condition has latched, so a degraded run
525
+ * can never be reported as a clean success. Refuses to commit twice.
526
+ */
527
+ function commitResult(stopReason: string): void {
528
+ if (state.committed) return;
529
+ if (state.terminal !== undefined) {
530
+ writeTerminalRecord(state.terminal);
531
+ return;
532
+ }
533
+ // A hash proves the bytes are intact; it cannot prove they are complete.
534
+ // Only an approved terminal stop reason may be committed as success, so a
535
+ // response cut short by the output-token limit, a content filter, an
536
+ // aborted run, or a provider error can never be returned as a whole answer.
537
+ if (!ACCEPTED_STOP_REASONS.has(stopReason)) {
538
+ writeTerminalRecord({
539
+ code: stopReason === 'length' ? 'child_model_output_limit' : 'child_result_invalid',
540
+ message: `the delegate child stopped with reason "${stopReason}", so its answer is incomplete and is not committed as a result; the complete assistant message remains in the child transcript`,
541
+ });
542
+ return;
543
+ }
544
+ if (state.answerBlocks.length === 0) {
545
+ writeTerminalRecord({
546
+ code: 'child_exited_without_commit',
547
+ message: 'the delegate child produced no final assistant answer text',
548
+ });
549
+ return;
550
+ }
551
+ if (state.answerBlocks.join('').trim().length === 0) {
552
+ writeTerminalRecord({
553
+ code: 'child_result_invalid',
554
+ message: 'the delegate child produced only whitespace, which is not a usable answer',
555
+ });
556
+ return;
557
+ }
558
+ writeRuntimeBudgetRecord();
559
+ const pkg = buildDelegateResultPackage({
560
+ taskId: seed.task_id,
561
+ launchNonce: seed.launch_nonce,
562
+ seedSha256: expectedSeedSha,
563
+ directiveSha256: seed.directive.sha256,
564
+ route: { provider: seed.route.provider, model: seed.route.model },
565
+ routeAttestations: state.attestations,
566
+ stopReason,
567
+ turns: state.turns,
568
+ toolCalls: state.toolCalls,
569
+ usage: usageReport(),
570
+ answerBlocks: state.answerBlocks,
571
+ spilledArtifacts: state.spilled,
572
+ });
573
+ commitFileSync(join(artifactDirAbs, 'result.json'), utf8(serializeDelegateResultPackage(pkg)));
574
+ state.committed = true;
575
+ }
576
+
577
+ function writeTerminalRecord(terminal: TerminalLatch): void {
578
+ writeRuntimeBudgetRecord();
579
+ commitFileSync(
580
+ join(artifactDirAbs, 'child-terminal.json'),
581
+ utf8(
582
+ `${JSON.stringify(
583
+ {
584
+ schema_version: 'pi-background-tasks.delegate-child-terminal.v1',
585
+ task_id: seed.task_id,
586
+ launch_nonce: seed.launch_nonce,
587
+ code: terminal.code,
588
+ message: terminal.message,
589
+ turns: state.turns,
590
+ tool_calls: state.toolCalls,
591
+ spilled_artifacts: state.spilled,
592
+ },
593
+ null,
594
+ 2,
595
+ )}\n`,
596
+ ),
597
+ );
598
+ }
599
+
600
+ pi.registerTool<typeof ArtifactReadParams, ArtifactReadDetails>({
601
+ name: 'delegate_read_artifact',
602
+ label: 'Delegate Artifact Read',
603
+ description:
604
+ 'Read an exact byte range from a spilled tool-result artifact as lossless base64. Returns the complete requested range or fails; it never decodes arbitrary bytes as UTF-8, returns fewer bytes, or clamps the request.',
605
+ promptSnippet: 'Read an exact artifact byte range as lossless base64',
606
+ promptGuidelines: [
607
+ 'Use delegate_read_artifact when a tool result was replaced by a spill receipt and the omitted bytes are actually needed.',
608
+ 'The response body is base64 for the exact requested bytes. Decode it according to the receipt content_format.',
609
+ 'Request a bounded range. A request past the end of the artifact fails loudly rather than returning a short read.',
610
+ ],
611
+ parameters: ArtifactReadParams,
612
+ prepareArguments(args): ArtifactReadParamsValue {
613
+ if (typeof args !== 'object' || args === null)
614
+ throw new Error('delegate_read_artifact arguments must be an object');
615
+ const artifact: unknown = Reflect.get(args, 'artifact');
616
+ const offset: unknown = Reflect.get(args, 'offset');
617
+ const length: unknown = Reflect.get(args, 'length');
618
+ if (typeof artifact !== 'string')
619
+ throw new Error('delegate_read_artifact requires artifact string');
620
+ if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
621
+ throw new Error('delegate_read_artifact requires a non-negative integer offset');
622
+ if (typeof length !== 'number' || !Number.isSafeInteger(length) || length <= 0)
623
+ throw new Error('delegate_read_artifact requires a positive integer length');
624
+ return { artifact, offset, length };
625
+ },
626
+ execute(_toolCallId, params) {
627
+ const absPath = join(artifactDirAbs, params.artifact);
628
+ if (!pathInside(artifactDirAbs, absPath)) {
629
+ throw new Error(
630
+ `delegate_read_artifact path ${params.artifact} escapes the delegate artifact directory`,
631
+ );
632
+ }
633
+ const bytes = readFileSync(absPath);
634
+ const end = params.offset + params.length;
635
+ if (end > bytes.length) {
636
+ throw new Error(
637
+ `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`,
638
+ );
639
+ }
640
+ const slice = bytes.subarray(params.offset, end);
641
+ if (slice.length !== params.length) {
642
+ throw new Error(
643
+ `delegate_read_artifact returned ${String(slice.length)} of ${String(params.length)} requested bytes`,
644
+ );
645
+ }
646
+ const encoded = slice.toString('base64');
647
+ const responseText = [
648
+ `[delegate artifact range] artifact=${params.artifact} offset=${String(params.offset)} length=${String(params.length)} encoding=base64`,
649
+ encoded,
650
+ ].join('\n');
651
+ const responseBytes = utf8(responseText).length;
652
+ const availableInlineBytes = Math.max(
653
+ 0,
654
+ Math.min(
655
+ seed.limits.max_tool_result_bytes,
656
+ remainingGrowthTokens() - DELEGATE_FINALIZATION_TRIGGER_TOKENS,
657
+ ),
658
+ );
659
+ if (responseBytes > availableInlineBytes) {
660
+ if (availableInlineBytes === 0) {
661
+ requestFinalization('no retained-growth runway remains for an artifact range read');
662
+ throw new Error(
663
+ `delegate_read_artifact cannot retain another artifact range because protected final-answer runway is active; stop reading and answer from gathered evidence`,
664
+ );
665
+ }
666
+ throw new Error(
667
+ `delegate_read_artifact requested ${String(params.length)} raw bytes whose lossless base64 response is ${String(responseBytes)} bytes, but current protected runway permits at most ${String(availableInlineBytes)} inline bytes; request a smaller exact range`,
668
+ );
669
+ }
670
+ return Promise.resolve({
671
+ content: [{ type: 'text' as const, text: responseText }],
672
+ details: {
673
+ artifact: params.artifact,
674
+ offset: params.offset,
675
+ length: params.length,
676
+ total_bytes: bytes.length,
677
+ },
678
+ });
679
+ },
680
+ });
681
+
682
+ pi.on('context', (event, ctx) => {
683
+ // Measurement failures remain fail-closed. A successful estimate is
684
+ // advisory: Fusion BUG-185 proved that subtracting hypothetical output from
685
+ // a live provider payload can falsely refuse valid work. Package-owned
686
+ // growth is bounded earlier by the tool-result spill governor instead.
687
+ try {
688
+ if (state.terminal !== undefined) {
689
+ ctx.abort();
690
+ return { messages: suppressedMessages(event.messages) };
691
+ }
692
+ const measurement = retainedInputMeasurement(event.messages, ctx.getSystemPrompt());
693
+ const verdict = evaluateDelegateRuntimeBudget(
694
+ {
695
+ retainedInputBytes: measurement.bytes,
696
+ retainedInputMultibyteBytes: measurement.multibyteBytes,
697
+ retainedInputDenseBytes: measurement.denseBytes,
698
+ },
699
+ seed.limits.allowed_input_tokens,
700
+ seed.route,
701
+ );
702
+ if (state.retainedGrowthBudgetTokens === undefined) {
703
+ state.retainedGrowthBudgetTokens = Math.max(
704
+ 0,
705
+ verdict.allowedTokens -
706
+ verdict.measuredTokens -
707
+ DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
708
+ );
709
+ }
710
+ state.contextMeasurements.push({
711
+ request_ordinal: state.contextMeasurements.length + 1,
712
+ retained_utf8_bytes: measurement.bytes,
713
+ estimated_input_tokens: verdict.measuredTokens,
714
+ allowed_input_tokens: verdict.allowedTokens,
715
+ signed_headroom_tokens: verdict.allowedTokens - verdict.measuredTokens,
716
+ dominant_byte_class: verdict.dominantByteClass,
717
+ finalization_requested: state.finalizationRequested,
718
+ });
719
+ if (
720
+ verdict.measuredTokens >=
721
+ verdict.allowedTokens - DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS
722
+ ) {
723
+ requestFinalization(
724
+ `advisory retained-input estimate reached ${String(verdict.measuredTokens)} of ${String(verdict.allowedTokens)} allowed tokens`,
725
+ );
726
+ }
727
+ if (!state.finalizationRequested) return undefined;
728
+ const finalizationMessage = {
729
+ role: 'user' as const,
730
+ content: `[delegate finalization runway] Stop investigating and do not call tools. Produce the final self-contained answer now from evidence already gathered. Reason: ${state.finalizationReason ?? 'protected final-answer runway is active'}.`,
731
+ timestamp: Date.now(),
732
+ };
733
+ return { messages: [...event.messages, finalizationMessage] };
734
+ } catch (error) {
735
+ latch(
736
+ 'child_result_invalid',
737
+ `delegate context measurement failed and the run was stopped rather than dispatched unguarded: ${error instanceof Error ? error.message : String(error)}`,
738
+ );
739
+ try {
740
+ ctx.abort();
741
+ } catch {
742
+ // The terminal latch prevents success even if abort itself fails.
743
+ }
744
+ return { messages: suppressedMessages(event.messages) };
745
+ }
746
+ });
747
+
748
+ pi.on('tool_call', (_event, ctx) => {
749
+ if (state.finalizationRequested) {
750
+ return {
751
+ block: true,
752
+ reason: 'delegate protected final-answer runway is active; answer now without tools',
753
+ };
754
+ }
755
+ state.toolCalls += 1;
756
+ if (state.toolCalls > seed.limits.max_tool_calls) {
757
+ latch(
758
+ 'child_tool_call_limit',
759
+ `delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
760
+ );
761
+ ctx.abort();
762
+ return {
763
+ block: true,
764
+ reason: `delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
765
+ };
766
+ }
767
+ return undefined;
768
+ });
769
+
770
+ pi.on('tool_result', (event) => {
771
+ // Fail closed for the same reason as the context guard: a throw here would
772
+ // let the ORIGINAL oversized payload flow into the transcript.
773
+ try {
774
+ return guardToolResult(event);
775
+ } catch (error) {
776
+ latch(
777
+ 'artifact_spill_failed',
778
+ `delegate tool-result guard failed and the payload was withheld: ${error instanceof Error ? error.message : String(error)}`,
779
+ );
780
+ return {
781
+ content: [
782
+ {
783
+ type: 'text' as const,
784
+ text: '[delegate: tool result withheld because the result guard failed; the run is terminating]',
785
+ },
786
+ ],
787
+ isError: true,
788
+ };
789
+ }
790
+ });
791
+
792
+ function guardToolResult(event: {
793
+ toolName: string;
794
+ toolCallId: string;
795
+ content: ReadonlyArray<DelegateToolResultPart>;
796
+ }): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } | undefined {
797
+ // A single text block keeps its exact UTF-8 payload for convenient range
798
+ // reads. Multi-block and image-bearing results use a closed JSON envelope
799
+ // so block boundaries, MIME types, and complete base64 image data survive
800
+ // a spill. Unknown blocks fail closed rather than disappearing from hashes.
801
+ const encodedContent = encodeToolResultContent(event.content);
802
+ const payload = encodedContent.payload;
803
+ state.totalToolOutputBytes += payload.length;
804
+ if (state.totalToolOutputBytes > seed.limits.max_total_tool_output_bytes) {
805
+ latch(
806
+ 'aggregate_tool_output_cap',
807
+ `delegate child accumulated ${String(state.totalToolOutputBytes)} bytes of tool output, exceeding its ${String(seed.limits.max_total_tool_output_bytes)}-byte cap`,
808
+ );
809
+ requestFinalization('the aggregate raw tool-output cap was reached');
810
+ const withheld = '[delegate: tool result withheld because the aggregate raw-output cap was reached; answer from evidence already gathered]';
811
+ accountRetainedGrowth(utf8(withheld).length, 'aggregate-cap receipt');
812
+ return { content: [{ type: 'text', text: withheld }], isError: true };
813
+ }
814
+ const contextPressure =
815
+ state.retainedGrowthBudgetTokens === undefined || payload.length > remainingGrowthTokens();
816
+ if (!contextPressure && payload.length <= seed.limits.max_tool_result_bytes) {
817
+ state.retainedToolResultBytes += payload.length;
818
+ accountRetainedGrowth(payload.length, `${event.toolName} inline result`);
819
+ return undefined;
820
+ }
821
+
822
+ // Per-result oversized or route-pressure output is spilled in full and
823
+ // replaced by a receipt. Conservative false positives cause an explicit
824
+ // spill, never task failure or silent byte loss.
825
+ const turnSequence = state.turns;
826
+ const sourceCallIndex = state.spilled.length;
827
+ const safeCallId = event.toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 64);
828
+ const name = `t${String(turnSequence).padStart(4, '0')}-c${String(sourceCallIndex).padStart(4, '0')}-${safeCallId}.bin`;
829
+ const relPath = join(SPILL_DIRNAME, name);
830
+ const absPath = join(artifactDirAbs, relPath);
831
+ if (!pathInside(artifactDirAbs, absPath)) {
832
+ latch('artifact_spill_failed', `delegate spill path escapes the artifact directory: ${name}`);
833
+ return {
834
+ content: [
835
+ {
836
+ type: 'text' as const,
837
+ text: '[delegate: tool result withheld because its spill path was rejected]',
838
+ },
839
+ ],
840
+ };
841
+ }
842
+ try {
843
+ commitFileSync(absPath, payload);
844
+ } catch (error) {
845
+ // A spill that cannot be committed is terminal. The original payload is
846
+ // never returned as a fallback, and no receipt claims an uncommitted file.
847
+ latch(
848
+ 'artifact_spill_failed',
849
+ `delegate could not spill a ${String(payload.length)}-byte tool result: ${error instanceof Error ? error.message : String(error)}`,
850
+ );
851
+ return {
852
+ content: [
853
+ {
854
+ type: 'text' as const,
855
+ text: '[delegate: tool result withheld because it could not be durably spilled; the run is terminating]',
856
+ },
857
+ ],
858
+ isError: true,
859
+ };
860
+ }
861
+ const receipt: DelegateSpillReceipt = {
862
+ schema_version: DELEGATE_RECEIPT_SCHEMA_VERSION,
863
+ artifact: relPath,
864
+ tool_name: event.toolName,
865
+ tool_call_id: event.toolCallId,
866
+ turn_sequence: turnSequence,
867
+ source_call_index: sourceCallIndex,
868
+ byte_length: payload.length,
869
+ sha256: sha256(payload),
870
+ content_format: encodedContent.contentFormat,
871
+ };
872
+ state.spilled.push(receipt);
873
+ if (contextPressure) state.contextPressureSpillBytes += payload.length;
874
+ const spillReason = contextPressure
875
+ ? `retaining it would consume protected final-answer runway (${String(remainingGrowthTokens())} tokens remain)`
876
+ : `it exceeded the ${String(seed.limits.max_tool_result_bytes)}-byte per-result transcript cap`;
877
+ const receiptText = [
878
+ `[delegate spill receipt] The ${event.toolName} result was ${String(payload.length)} bytes; ${spillReason}.`,
879
+ `It was written in full to ${relPath} (sha256 ${receipt.sha256}, content_format ${encodedContent.contentFormat}).`,
880
+ 'Nothing was truncated: the complete encoded content is on disk.',
881
+ `Read an exact range as base64 with delegate_read_artifact({artifact:"${relPath}", offset, length}).`,
882
+ ].join('\n');
883
+ accountRetainedGrowth(utf8(receiptText).length, `${event.toolName} spill receipt`);
884
+ return {
885
+ content: [{ type: 'text' as const, text: receiptText }],
886
+ };
887
+ }
888
+
889
+ pi.on('turn_start', () => {
890
+ state.turns += 1;
891
+ if (state.turns > seed.limits.max_turns) {
892
+ latch(
893
+ 'child_turn_limit',
894
+ `delegate child exceeded its ${String(seed.limits.max_turns)} turn limit`,
895
+ );
896
+ }
897
+ });
898
+
899
+ pi.on('message_end', (event) => {
900
+ if (event.message.role !== 'assistant') return;
901
+ const provider: unknown = Reflect.get(event.message, 'provider');
902
+ const model: unknown = Reflect.get(event.message, 'model');
903
+ const stopReason: unknown = Reflect.get(event.message, 'stopReason');
904
+ const attestation: DelegateRouteAttestation = {
905
+ provider: typeof provider === 'string' ? provider : '',
906
+ model: typeof model === 'string' ? model : '',
907
+ stop_reason: typeof stopReason === 'string' ? stopReason : '',
908
+ };
909
+ state.attestations.push(attestation);
910
+ if (
911
+ attestation.provider !== seed.route.provider ||
912
+ attestation.model !== seed.route.model
913
+ ) {
914
+ latch(
915
+ 'route_mismatch',
916
+ `delegate child produced an assistant message on ${attestation.provider}/${attestation.model}, but the pinned route is ${seed.route.qualified_id}`,
917
+ );
918
+ }
919
+ const observedUsage = readUsage(Reflect.get(event.message, 'usage'));
920
+ if (observedUsage === undefined) {
921
+ // Never synthesize zero usage. Once one turn is incomplete, later usage
922
+ // cannot conceal it by replacing the missing record.
923
+ state.usageIncomplete = true;
924
+ state.usageUnavailableReason =
925
+ 'at least one provider turn did not report a complete token/cost usage record';
926
+ } else {
927
+ if (state.firstRequestObservedInputTokens === undefined) {
928
+ state.firstRequestObservedInputTokens =
929
+ observedUsage.input + observedUsage.cacheRead + observedUsage.cacheWrite;
930
+ }
931
+ state.usage = addUsage(state.usage, observedUsage);
932
+ if (!state.usageIncomplete) state.usageUnavailableReason = undefined;
933
+ }
934
+ const content: unknown = Reflect.get(event.message, 'content');
935
+ if (!Array.isArray(content)) return;
936
+ if (attestation.stop_reason !== 'stop') {
937
+ const retained = messageContentMeasurement(content);
938
+ accountRetainedGrowth(retained.bytes, 'assistant intermediate message');
939
+ // Tool-use narration is retained in the transcript for reasoning but is
940
+ // not part of the delegate's committed answer. Only the final clean-stop
941
+ // assistant message owns the answer data plane.
942
+ return;
943
+ }
944
+ state.answerBlocks = [];
945
+ state.answerBytes = 0;
946
+ for (const part of content) {
947
+ if (typeof part !== 'object' || part === null) continue;
948
+ if (Reflect.get(part, 'type') !== 'text') continue;
949
+ const text: unknown = Reflect.get(part, 'text');
950
+ if (typeof text !== 'string' || text.length === 0) continue;
951
+ const bytes = utf8(text).length;
952
+ state.answerBytes += bytes;
953
+ if (state.answerBytes > seed.limits.max_answer_bytes) {
954
+ latch(
955
+ 'child_capture_limit',
956
+ `delegate child answer text reached ${String(state.answerBytes)} bytes, exceeding its ${String(seed.limits.max_answer_bytes)}-byte capture contract; the transcript is preserved and no prefix is committed`,
957
+ );
958
+ continue;
959
+ }
960
+ state.answerBlocks.push(text);
961
+ }
962
+ });
963
+
964
+ pi.on('agent_end', () => {
965
+ const finalStop = state.attestations.at(-1)?.stop_reason ?? 'unknown';
966
+ commitResult(finalStop);
967
+ });
968
+
969
+ pi.on('session_shutdown', () => {
970
+ // A shutdown before agent_end means no answer was produced. Record it so the
971
+ // parent sees a typed reason instead of an empty directory.
972
+ if (state.committed) return;
973
+ if (state.terminal === undefined) {
974
+ latch('child_exited_without_commit', 'the delegate child shut down before committing a result');
975
+ }
976
+ if (state.terminal !== undefined) writeTerminalRecord(state.terminal);
977
+ });
978
+ }