pi-background-tasks 2.1.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/PUBLISHING.md +2 -0
  3. package/README.md +16 -8
  4. package/TESTING.md +23 -14
  5. package/TEST_PLAN.md +13 -12
  6. package/THIRD_PARTY_NOTICES.md +30 -0
  7. package/docs/INDEX.md +8 -4
  8. package/docs/choose-a-workflow.md +5 -2
  9. package/docs/commands/claude-cache.md +50 -0
  10. package/docs/concepts/context-projection-and-budgeting.md +4 -2
  11. package/docs/getting-started.md +3 -0
  12. package/docs/manifest.json +86 -22
  13. package/docs/operations/configuration.md +15 -1
  14. package/docs/operations/releasing.md +6 -3
  15. package/docs/operations/troubleshooting.md +3 -1
  16. package/docs/read-before-edit.md +4 -1
  17. package/docs/reference/runtime-contracts.md +53 -53
  18. package/docs/subsystems/anthropic-attribution.md +63 -0
  19. package/docs/subsystems/attested-pi-runs.md +2 -2
  20. package/docs/subsystems/child-launch-durability-and-safety.md +3 -3
  21. package/docs/subsystems/delegation.md +34 -17
  22. package/docs/subsystems/docs-freshness-gate.md +6 -6
  23. package/docs/subsystems/fusion.md +2 -2
  24. package/docs/tools/bg_delegate.md +33 -16
  25. package/docs/tools/bg_result.md +2 -2
  26. package/docs/tools/bg_run.md +5 -0
  27. package/extensions/anthropic-attribution.ts +1 -0
  28. package/package.json +4 -2
  29. package/src/core/anthropic-attribution-path.ts +26 -0
  30. package/src/core/{fusion/anthropic-attribution.ts → anthropic-attribution.ts} +61 -8
  31. package/src/core/attested-pi-run.ts +10 -1
  32. package/src/core/common.ts +2 -1
  33. package/src/core/context/token-budget.ts +16 -3
  34. package/src/core/delegate/artifacts.ts +18 -12
  35. package/src/core/delegate/budget.ts +78 -33
  36. package/src/core/delegate/launch.ts +48 -16
  37. package/src/core/delegate/result-package.ts +16 -0
  38. package/src/core/delegate/runner.ts +45 -2
  39. package/src/core/delegate/seed.ts +12 -0
  40. package/src/core/delegate/types.ts +22 -3
  41. package/src/core/fusion/config.ts +1 -1
  42. package/src/core/fusion/pi-child.ts +7 -124
  43. package/src/core/registry.ts +4 -1
  44. package/src/delegate-child-extension.ts +377 -71
  45. package/src/delegate-extension.ts +58 -6
@@ -7,15 +7,21 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
7
7
  import type { Usage } from '@earendil-works/pi-ai';
8
8
  import {
9
9
  DELEGATE_RECEIPT_SCHEMA_VERSION,
10
+ DELEGATE_CAPABILITIES,
10
11
  type DelegateRouteAttestation,
11
12
  type DelegateSeedV1,
12
13
  type DelegateSpillReceipt,
13
14
  type DelegateUsageReport,
14
15
  } from './core/delegate/types.js';
15
16
  import { verifyDelegateSeedBytes } from './core/delegate/seed.js';
16
- import { evaluateDelegateRuntimeBudget } from './core/delegate/budget.js';
17
+ import {
18
+ DELEGATE_FINALIZATION_INPUT_RESERVE_TOKENS,
19
+ DELEGATE_FINALIZATION_TRIGGER_TOKENS,
20
+ evaluateDelegateRuntimeBudget,
21
+ } from './core/delegate/budget.js';
17
22
  import { utf8ByteClassBreakdown } from './core/context/token-budget.js';
18
23
  import {
24
+ assertWellFormedUtf8,
19
25
  buildDelegateResultPackage,
20
26
  serializeDelegateResultPackage,
21
27
  } from './core/delegate/result-package.js';
@@ -23,15 +29,17 @@ import {
23
29
  /**
24
30
  * Package-owned delegate child extension.
25
31
  *
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
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
28
36
  * parent:
29
37
  *
30
38
  * - 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;
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;
35
43
  * - asserting every assistant message came from the pinned route;
36
44
  * - enforcing turn and tool-call limits;
37
45
  * - committing exactly one result package atomically.
@@ -60,8 +68,19 @@ interface GuardState {
60
68
  spilled: DelegateSpillReceipt[];
61
69
  attestations: DelegateRouteAttestation[];
62
70
  usage: Usage | undefined;
71
+ usageIncomplete: boolean;
63
72
  usageUnavailableReason: string | undefined;
64
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;
65
84
  terminal: TerminalLatch | undefined;
66
85
  committed: boolean;
67
86
  }
@@ -79,6 +98,16 @@ interface TerminalLatch {
79
98
  message: string;
80
99
  }
81
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
+
82
111
  /**
83
112
  * Stop reasons that may be committed as a complete answer.
84
113
  *
@@ -112,6 +141,85 @@ function utf8(value: string): Buffer {
112
141
  return Buffer.from(value, 'utf8');
113
142
  }
114
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
+
115
223
  function finiteNonNegative(source: object, key: string): number | undefined {
116
224
  const value: unknown = Reflect.get(source, key);
117
225
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
@@ -289,6 +397,10 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
289
397
  launchNonce: expectedNonce,
290
398
  });
291
399
 
400
+ if (!DELEGATE_CAPABILITIES.includes(seed.capability)) {
401
+ throw new Error(`delegate child cannot enforce capability ${seed.capability}`);
402
+ }
403
+
292
404
  const state: GuardState = {
293
405
  seed,
294
406
  artifactDirAbs,
@@ -298,8 +410,19 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
298
410
  spilled: [],
299
411
  attestations: [],
300
412
  usage: undefined,
413
+ usageIncomplete: false,
301
414
  usageUnavailableReason: 'the child produced no assistant message carrying usage',
302
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,
303
426
  terminal: undefined,
304
427
  committed: false,
305
428
  };
@@ -309,13 +432,92 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
309
432
  }
310
433
 
311
434
  function usageReport(): DelegateUsageReport {
312
- if (state.usage !== undefined) return { status: 'observed', usage: state.usage };
435
+ if (!state.usageIncomplete && state.usage !== undefined) {
436
+ return { status: 'observed', usage: state.usage };
437
+ }
313
438
  return {
314
439
  status: 'unavailable',
315
440
  reason: state.usageUnavailableReason ?? 'usage was not reported by the provider',
316
441
  };
317
442
  }
318
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
+
319
521
  /**
320
522
  * Commit exactly one result package.
321
523
  *
@@ -328,13 +530,6 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
328
530
  writeTerminalRecord(state.terminal);
329
531
  return;
330
532
  }
331
- if (state.answerBlocks.length === 0) {
332
- writeTerminalRecord({
333
- code: 'child_exited_without_commit',
334
- message: 'the delegate child produced no assistant answer text',
335
- });
336
- return;
337
- }
338
533
  // A hash proves the bytes are intact; it cannot prove they are complete.
339
534
  // Only an approved terminal stop reason may be committed as success, so a
340
535
  // response cut short by the output-token limit, a content filter, an
@@ -342,7 +537,14 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
342
537
  if (!ACCEPTED_STOP_REASONS.has(stopReason)) {
343
538
  writeTerminalRecord({
344
539
  code: stopReason === 'length' ? 'child_model_output_limit' : 'child_result_invalid',
345
- 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`,
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',
346
548
  });
347
549
  return;
348
550
  }
@@ -353,6 +555,7 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
353
555
  });
354
556
  return;
355
557
  }
558
+ writeRuntimeBudgetRecord();
356
559
  const pkg = buildDelegateResultPackage({
357
560
  taskId: seed.task_id,
358
561
  launchNonce: seed.launch_nonce,
@@ -372,6 +575,7 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
372
575
  }
373
576
 
374
577
  function writeTerminalRecord(terminal: TerminalLatch): void {
578
+ writeRuntimeBudgetRecord();
375
579
  commitFileSync(
376
580
  join(artifactDirAbs, 'child-terminal.json'),
377
581
  utf8(
@@ -397,10 +601,11 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
397
601
  name: 'delegate_read_artifact',
398
602
  label: 'Delegate Artifact Read',
399
603
  description:
400
- '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.',
401
- promptSnippet: 'Read an exact byte range from a spilled tool-result artifact',
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',
402
606
  promptGuidelines: [
403
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.',
404
609
  'Request a bounded range. A request past the end of the artifact fails loudly rather than returning a short read.',
405
610
  ],
406
611
  parameters: ArtifactReadParams,
@@ -438,8 +643,32 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
438
643
  `delegate_read_artifact returned ${String(slice.length)} of ${String(params.length)} requested bytes`,
439
644
  );
440
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
+ }
441
670
  return Promise.resolve({
442
- content: [{ type: 'text' as const, text: slice.toString('utf8') }],
671
+ content: [{ type: 'text' as const, text: responseText }],
443
672
  details: {
444
673
  artifact: params.artifact,
445
674
  offset: params.offset,
@@ -451,12 +680,15 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
451
680
  });
452
681
 
453
682
  pi.on('context', (event, ctx) => {
454
- // Fail closed. Pi swallows exceptions thrown from a `context` handler and
455
- // dispatches the call regardless, so a throw inside this guard would let the
456
- // ORIGINAL unguarded message set reach the provider. Every path therefore
457
- // runs inside this try, and the catch latches terminal state and suppresses
458
- // the content rather than letting the original through.
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.
459
687
  try {
688
+ if (state.terminal !== undefined) {
689
+ ctx.abort();
690
+ return { messages: suppressedMessages(event.messages) };
691
+ }
460
692
  const measurement = retainedInputMeasurement(event.messages, ctx.getSystemPrompt());
461
693
  const verdict = evaluateDelegateRuntimeBudget(
462
694
  {
@@ -467,32 +699,74 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
467
699
  seed.limits.allowed_input_tokens,
468
700
  seed.route,
469
701
  );
470
- if (verdict.withinBudget) return undefined;
471
- 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`;
472
- latch('provider_context_budget_exhausted', message);
473
- // Barrier one: terminate the run. Depending on the supported Pi line,
474
- // this skips provider dispatch or hands it an already-aborted signal.
475
- ctx.abort();
476
- // Barrier two: remove the content itself, so the request could not carry it
477
- // even if a provider ignored the aborted signal. Retaining only the first
478
- // message keeps the shape valid without transmitting the oversized tail.
479
- return { messages: suppressedMessages(event.messages) };
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] };
480
734
  } catch (error) {
481
735
  latch(
482
736
  'child_result_invalid',
483
- `delegate context guard failed and the run was stopped rather than dispatched unguarded: ${error instanceof Error ? error.message : String(error)}`,
737
+ `delegate context measurement failed and the run was stopped rather than dispatched unguarded: ${error instanceof Error ? error.message : String(error)}`,
484
738
  );
485
739
  try {
486
740
  ctx.abort();
487
741
  } catch {
488
- // An abort failure must not resurrect the unguarded message set. The
489
- // latch above already prevents a success commit, and the suppressed
490
- // replacement below still removes the content from this request.
742
+ // The terminal latch prevents success even if abort itself fails.
491
743
  }
492
744
  return { messages: suppressedMessages(event.messages) };
493
745
  }
494
746
  });
495
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
+
496
770
  pi.on('tool_result', (event) => {
497
771
  // Fail closed for the same reason as the context guard: a throw here would
498
772
  // let the ORIGINAL oversized payload flow into the transcript.
@@ -518,31 +792,36 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
518
792
  function guardToolResult(event: {
519
793
  toolName: string;
520
794
  toolCallId: string;
521
- content: ReadonlyArray<{ type: string; text?: string }>;
795
+ content: ReadonlyArray<DelegateToolResultPart>;
522
796
  }): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } | undefined {
523
- state.toolCalls += 1;
524
- if (state.toolCalls > seed.limits.max_tool_calls) {
525
- latch(
526
- 'child_tool_call_limit',
527
- `delegate child exceeded its ${String(seed.limits.max_tool_calls)} tool-call limit`,
528
- );
529
- }
530
- const texts = event.content.flatMap((part) =>
531
- part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
532
- );
533
- const joined = texts.join('');
534
- const payload = utf8(joined);
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;
535
803
  state.totalToolOutputBytes += payload.length;
536
804
  if (state.totalToolOutputBytes > seed.limits.max_total_tool_output_bytes) {
537
805
  latch(
538
806
  'aggregate_tool_output_cap',
539
807
  `delegate child accumulated ${String(state.totalToolOutputBytes)} bytes of tool output, exceeding its ${String(seed.limits.max_total_tool_output_bytes)}-byte cap`,
540
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;
541
820
  }
542
- if (payload.length <= seed.limits.max_tool_result_bytes) return undefined;
543
821
 
544
- // Oversized: spill to a hashed artifact and replace the transcript content
545
- // with an explicit receipt. The raw payload never enters the transcript.
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.
546
825
  const turnSequence = state.turns;
547
826
  const sourceCallIndex = state.spilled.length;
548
827
  const safeCallId = event.toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 64);
@@ -588,20 +867,22 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
588
867
  source_call_index: sourceCallIndex,
589
868
  byte_length: payload.length,
590
869
  sha256: sha256(payload),
870
+ content_format: encodedContent.contentFormat,
591
871
  };
592
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`);
593
884
  return {
594
- content: [
595
- {
596
- type: 'text' as const,
597
- text: [
598
- `[delegate spill receipt] The ${event.toolName} result was ${String(payload.length)} bytes, over the ${String(seed.limits.max_tool_result_bytes)}-byte transcript cap.`,
599
- `It was written in full to ${relPath} (sha256 ${receipt.sha256}).`,
600
- 'Nothing was truncated: the complete bytes are on disk.',
601
- `Read an exact range with delegate_read_artifact({artifact:"${relPath}", offset, length}).`,
602
- ].join('\n'),
603
- },
604
- ],
885
+ content: [{ type: 'text' as const, text: receiptText }],
605
886
  };
606
887
  }
607
888
 
@@ -637,21 +918,46 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
637
918
  }
638
919
  const observedUsage = readUsage(Reflect.get(event.message, 'usage'));
639
920
  if (observedUsage === undefined) {
640
- // Never synthesize zero usage. An absent or incomplete usage record stays
641
- // explicitly unavailable so the parent cannot report a free run.
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;
642
924
  state.usageUnavailableReason =
643
- 'the provider did not report a complete token/cost usage record';
925
+ 'at least one provider turn did not report a complete token/cost usage record';
644
926
  } else {
645
- state.usage = observedUsage;
646
- state.usageUnavailableReason = undefined;
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;
647
933
  }
648
934
  const content: unknown = Reflect.get(event.message, 'content');
649
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;
650
946
  for (const part of content) {
651
947
  if (typeof part !== 'object' || part === null) continue;
652
948
  if (Reflect.get(part, 'type') !== 'text') continue;
653
949
  const text: unknown = Reflect.get(part, 'text');
654
- if (typeof text === 'string' && text.length > 0) state.answerBlocks.push(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);
655
961
  }
656
962
  });
657
963