pi-background-tasks 1.0.7 → 2.1.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 (55) hide show
  1. package/README.md +8 -8
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +6 -6
  4. package/docs/INDEX.md +25 -25
  5. package/docs/choose-a-workflow.md +4 -4
  6. package/docs/commands/bg-clear.md +1 -1
  7. package/docs/commands/bg-update.md +1 -1
  8. package/docs/commands/bg.md +1 -1
  9. package/docs/commands/fusion-models.md +1 -1
  10. package/docs/commands/fusion.md +5 -8
  11. package/docs/commands/jobs.md +1 -1
  12. package/docs/commands/kill.md +1 -1
  13. package/docs/commands/logs.md +1 -1
  14. package/docs/commands/task-manager.md +2 -2
  15. package/docs/concepts/completion-delivery.md +1 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/manifest.json +69 -50
  18. package/docs/operations/configuration.md +5 -3
  19. package/docs/read-before-edit.md +3 -0
  20. package/docs/reference/runtime-contracts.md +87 -82
  21. package/docs/reference/shortcuts-and-dock.md +2 -2
  22. package/docs/subsystems/background-task-runtime.md +7 -1
  23. package/docs/subsystems/docs-freshness-gate.md +5 -5
  24. package/docs/subsystems/fusion.md +22 -15
  25. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  26. package/docs/tools/bg_delegate.md +1 -1
  27. package/docs/tools/bg_kill.md +1 -1
  28. package/docs/tools/bg_logs.md +1 -1
  29. package/docs/tools/bg_result.md +14 -10
  30. package/docs/tools/bg_run.md +1 -1
  31. package/docs/tools/bg_run_pi_attested.md +1 -1
  32. package/docs/tools/bg_status.md +1 -1
  33. package/docs/tools/fusion_investigate.md +6 -4
  34. package/docs/tools/fusion_reason.md +5 -5
  35. package/docs/tools/fusion_research.md +6 -2
  36. package/docs/tools/fusion_validate.md +5 -3
  37. package/package.json +1 -1
  38. package/src/core/common.ts +50 -2
  39. package/src/core/fusion/anthropic-attribution.ts +1930 -0
  40. package/src/core/fusion/artifacts.ts +168 -21
  41. package/src/core/fusion/budget.ts +23 -23
  42. package/src/core/fusion/child-protocol.ts +115 -10
  43. package/src/core/fusion/claude-cache.ts +21 -0
  44. package/src/core/fusion/config.ts +10 -2
  45. package/src/core/fusion/orchestrator.ts +281 -77
  46. package/src/core/fusion/output-contract.ts +34 -0
  47. package/src/core/fusion/pi-child.ts +420 -12
  48. package/src/core/fusion/prompts.ts +11 -1
  49. package/src/core/fusion/result-package.ts +412 -0
  50. package/src/core/fusion/types.ts +67 -0
  51. package/src/core/registry.ts +187 -20
  52. package/src/delegate-extension.ts +130 -24
  53. package/src/extension.ts +17 -6
  54. package/src/fusion-child-extension.ts +117 -2
  55. package/src/fusion-extension.ts +308 -154
@@ -1,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { chmod, mkdir } from 'node:fs/promises';
3
3
  import { basename, isAbsolute, join, relative, sep } from 'node:path';
4
4
  import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
@@ -6,6 +6,7 @@ import { sanitizePathSegment } from '../common.js';
6
6
  import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
+ FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
9
10
  FUSION_MANIFEST_SCHEMA_VERSION,
10
11
  FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
11
12
  FusionError,
@@ -16,9 +17,12 @@ import {
16
17
  type FusionBudgetPlanV1,
17
18
  type FusionCalibrationViolation,
18
19
  type FusionCandidateId,
20
+ type FusionCandidateOutputRecovery,
19
21
  type FusionCapability,
20
22
  type FusionContextOmissionLedgerV2,
21
23
  type FusionChildRunResult,
24
+ type FusionCommittedResultV1,
25
+ type FusionResultDetails,
22
26
  type FusionModelConfigV1,
23
27
  type FusionSource,
24
28
  type FusionStage,
@@ -29,6 +33,10 @@ import {
29
33
  type ResolvedFusionModels,
30
34
  } from './types.js';
31
35
  import { fusionWorkflowProfile, type FusionWorkflowProfile } from './workflows.js';
36
+ import {
37
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
38
+ fusionJsonRenderedTextBytes,
39
+ } from './output-contract.js';
32
40
 
33
41
  /**
34
42
  * Run ids are prefixed by workflow so an artifact directory is self-describing.
@@ -57,8 +65,17 @@ interface MutableFusionArtifactManifest {
57
65
  evaluation: FusionCapability;
58
66
  merge: FusionCapability;
59
67
  };
60
- context: { kind: import('./types.js').FusionContextKind; policy_id: string; ledger_artifact?: string; source_policy_artifact?: string };
61
- tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
68
+ context: {
69
+ kind: import('./types.js').FusionContextKind;
70
+ policy_id: string;
71
+ ledger_artifact?: string;
72
+ source_policy_artifact?: string;
73
+ };
74
+ tool_policy: {
75
+ candidate_tools: readonly string[];
76
+ evaluation_tools: readonly [];
77
+ merge_tools: readonly [];
78
+ };
62
79
  usage: FusionUsage;
63
80
  attempts: FusionAttemptArtifactRecord[];
64
81
  artifacts: Record<string, FusionArtifactRef>;
@@ -123,7 +140,9 @@ export interface RecordFusionFailedAttemptInput {
123
140
  stderr: Buffer;
124
141
  error: string;
125
142
  status: 'failed' | 'cancelled';
143
+ childCreated: boolean;
126
144
  responseKind: 'md' | 'txt';
145
+ outputRecovery?: FusionCandidateOutputRecovery;
127
146
  provider?: string;
128
147
  model?: string;
129
148
  qualifiedId?: string;
@@ -206,7 +225,11 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
206
225
  models: manifest.models,
207
226
  capabilities: manifest.capabilities,
208
227
  context: { ...manifest.context },
209
- tool_policy: { candidate_tools: [...manifest.tool_policy.candidate_tools], evaluation_tools: [], merge_tools: [] },
228
+ tool_policy: {
229
+ candidate_tools: [...manifest.tool_policy.candidate_tools],
230
+ evaluation_tools: [],
231
+ merge_tools: [],
232
+ },
210
233
  usage: cloneFusionUsage(manifest.usage),
211
234
  attempts: [...manifest.attempts],
212
235
  artifacts: { ...manifest.artifacts },
@@ -234,6 +257,59 @@ function calibrationViolationName(prefix: string): string {
234
257
  return `${prefix}.calibration-violation.json`;
235
258
  }
236
259
 
260
+ function oversizedResponseName(prefix: string, kind: 'md' | 'txt'): string {
261
+ return `${prefix}.response.oversized.${kind}`;
262
+ }
263
+
264
+ function validateOutputRecovery(
265
+ recovery: FusionCandidateOutputRecovery,
266
+ replacementText?: string,
267
+ ): void {
268
+ if (recovery.limit_bytes !== FUSION_CANDIDATE_MAX_OUTPUT_BYTES) {
269
+ throw errorForArtifact('fusion output recovery limit mismatches the candidate contract');
270
+ }
271
+ const originalBytes = Buffer.from(recovery.original_text, 'utf8');
272
+ if (createHash('sha256').update(originalBytes).digest('hex') !== recovery.original_text_sha256) {
273
+ throw errorForArtifact('fusion output recovery original text hash mismatch');
274
+ }
275
+ if (
276
+ fusionJsonRenderedTextBytes(recovery.original_text) !== recovery.original_json_rendered_bytes
277
+ ) {
278
+ throw errorForArtifact('fusion output recovery original JSON-rendered byte count mismatch');
279
+ }
280
+ if (recovery.original_json_rendered_bytes <= recovery.limit_bytes) {
281
+ throw errorForArtifact('fusion output recovery original did not exceed the candidate contract');
282
+ }
283
+ if (replacementText !== undefined) {
284
+ const replacementBytes = fusionJsonRenderedTextBytes(replacementText);
285
+ if (replacementBytes !== recovery.replacement_json_rendered_bytes) {
286
+ throw errorForArtifact(
287
+ 'fusion output recovery replacement JSON-rendered byte count mismatch',
288
+ );
289
+ }
290
+ if (recovery.status === 'completed' && replacementBytes > recovery.limit_bytes) {
291
+ throw errorForArtifact('completed fusion output recovery replacement exceeds the contract');
292
+ }
293
+ }
294
+ }
295
+
296
+ function outputRecoveryRecord(
297
+ recovery: FusionCandidateOutputRecovery,
298
+ path: string,
299
+ ): NonNullable<FusionAttemptArtifactRecord['output_recovery']> {
300
+ return {
301
+ kind: recovery.kind,
302
+ status: recovery.status,
303
+ limit_bytes: recovery.limit_bytes,
304
+ original_response_path: path,
305
+ original_record_index: recovery.original_record_index,
306
+ replacement_record_index: recovery.replacement_record_index,
307
+ original_json_rendered_bytes: recovery.original_json_rendered_bytes,
308
+ replacement_json_rendered_bytes: recovery.replacement_json_rendered_bytes,
309
+ original_text_sha256: recovery.original_text_sha256,
310
+ };
311
+ }
312
+
237
313
  function artifactRefSha256Hex(value: string): string {
238
314
  const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value;
239
315
  if (!/^[0-9a-f]{64}$/u.test(hex)) {
@@ -295,8 +371,18 @@ export class FusionArtifactStore {
295
371
  evaluation: 'reason',
296
372
  merge: 'reason',
297
373
  },
298
- context: { kind: profile.contextKind, policy_id: profile.contextKind === 'session_projection' ? 'fusion-session-projection-v1' : 'fusion-clean-task-v1' },
299
- tool_policy: { candidate_tools: profile.candidateTools, evaluation_tools: [], merge_tools: [] },
374
+ context: {
375
+ kind: profile.contextKind,
376
+ policy_id:
377
+ profile.contextKind === 'session_projection'
378
+ ? 'fusion-session-projection-v1'
379
+ : 'fusion-clean-task-v1',
380
+ },
381
+ tool_policy: {
382
+ candidate_tools: profile.candidateTools,
383
+ evaluation_tools: [],
384
+ merge_tools: [],
385
+ },
300
386
  usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
301
387
  attempts: [],
302
388
  artifacts: {},
@@ -327,6 +413,12 @@ export class FusionArtifactStore {
327
413
  return this.artifactPath(`${attemptPrefix(stage, slot, attempt)}.tool-calls.jsonl`);
328
414
  }
329
415
 
416
+ childOutputRecoveryPath(slot: 1 | 2 | 3, attempt: number, responseKind: 'md' | 'txt'): string {
417
+ return this.artifactPath(
418
+ oversizedResponseName(attemptPrefix('candidate', slot, attempt), responseKind),
419
+ );
420
+ }
421
+
330
422
  snapshot(): FusionArtifactManifest {
331
423
  return publicManifest(this.manifest);
332
424
  }
@@ -339,11 +431,18 @@ export class FusionArtifactStore {
339
431
  childCreated: false,
340
432
  });
341
433
  }
342
- if (to === 'completed' && manifest.artifacts['merged.md'] === undefined) {
343
- throw new FusionError('fusion cannot complete before merged.md is durable', {
344
- code: 'state_transition_invalid',
345
- childCreated: false,
346
- });
434
+ if (
435
+ to === 'completed' &&
436
+ (manifest.artifacts['merged.md'] === undefined ||
437
+ manifest.artifacts['result.json'] === undefined)
438
+ ) {
439
+ throw new FusionError(
440
+ 'fusion cannot complete before merged.md and result.json are durable',
441
+ {
442
+ code: 'state_transition_invalid',
443
+ childCreated: false,
444
+ },
445
+ );
347
446
  }
348
447
  manifest.state = to;
349
448
  });
@@ -403,8 +502,21 @@ export class FusionArtifactStore {
403
502
  await this.writeArtifact('evaluation.json', canonicalJson(value));
404
503
  }
405
504
 
406
- async writeMerged(text: string): Promise<void> {
407
- await this.writeArtifact('merged.md', text);
505
+ async writeMerged(text: string): Promise<FusionArtifactRef> {
506
+ return this.writeArtifact('merged.md', text);
507
+ }
508
+
509
+ async writeCommittedResult(
510
+ merged: FusionArtifactRef,
511
+ details: FusionResultDetails,
512
+ ): Promise<FusionArtifactRef> {
513
+ const value: FusionCommittedResultV1 = {
514
+ schema_version: FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
515
+ run_id: this.runId,
516
+ merged,
517
+ details,
518
+ };
519
+ return this.writeArtifact('result.json', `${canonicalJson(value)}\n`);
408
520
  }
409
521
 
410
522
  async writeError(state: Exclude<FusionTerminalState, 'completed'>, error: string): Promise<void> {
@@ -436,11 +548,22 @@ export class FusionArtifactStore {
436
548
  input.result.toolCallTrace === undefined
437
549
  ? undefined
438
550
  : await this.writeArtifact(`${prefix}.tool-calls.jsonl`, input.result.toolCallTrace.bytes);
551
+ if (input.result.outputRecovery !== undefined) {
552
+ validateOutputRecovery(input.result.outputRecovery, input.result.text);
553
+ }
554
+ const outputRecoveryRef =
555
+ input.result.outputRecovery === undefined
556
+ ? undefined
557
+ : await this.writeArtifact(
558
+ oversizedResponseName(prefix, input.responseKind),
559
+ input.result.outputRecovery.original_text,
560
+ );
439
561
  await this.updateManifest((manifest) => {
440
562
  const record: FusionAttemptArtifactRecord = {
441
563
  stage: input.result.stage,
442
564
  attempt: input.result.attempt,
443
565
  status: 'completed',
566
+ child_created: true,
444
567
  prompt_path: promptRef.path,
445
568
  events_path: eventsRef.path,
446
569
  stderr_path: stderrRef.path,
@@ -454,6 +577,12 @@ export class FusionArtifactStore {
454
577
  record.tool_calls_path = toolCallsRef.path;
455
578
  record.tool_calls = { ...input.result.toolCallTrace.summary };
456
579
  }
580
+ if (outputRecoveryRef !== undefined && input.result.outputRecovery !== undefined) {
581
+ record.output_recovery = outputRecoveryRecord(
582
+ input.result.outputRecovery,
583
+ outputRecoveryRef.path,
584
+ );
585
+ }
457
586
  if (input.result.slot !== undefined) record.slot = input.result.slot;
458
587
  manifest.attempts.push(record);
459
588
  });
@@ -466,20 +595,26 @@ export class FusionArtifactStore {
466
595
  violation: FusionCalibrationViolation;
467
596
  }): Promise<FusionArtifactRef> {
468
597
  const prefix = attemptPrefix(input.stage, input.slot, input.attempt);
469
- return this.writeArtifact(calibrationViolationName(prefix), `${canonicalJson(input.violation)}\n`);
598
+ return this.writeArtifact(
599
+ calibrationViolationName(prefix),
600
+ `${canonicalJson(input.violation)}\n`,
601
+ );
470
602
  }
471
603
 
472
604
  async recordValidationCandidateContractEvent(
473
605
  input: RecordValidationCandidateContractEventInput,
474
606
  ): Promise<FusionArtifactRef> {
475
607
  const name = `candidate-${String(input.slot)}.output-contract-${input.status}.json`;
476
- return this.writeArtifact(name, `${canonicalJson({
477
- schema_version: FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
478
- ...input.detail,
479
- candidate_id: input.candidateId,
480
- slot: input.slot,
481
- status: input.status,
482
- })}\n`);
608
+ return this.writeArtifact(
609
+ name,
610
+ `${canonicalJson({
611
+ schema_version: FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
612
+ ...input.detail,
613
+ candidate_id: input.candidateId,
614
+ slot: input.slot,
615
+ status: input.status,
616
+ })}\n`,
617
+ );
483
618
  }
484
619
 
485
620
  async recordFailedAttempt(input: RecordFusionFailedAttemptInput): Promise<void> {
@@ -496,11 +631,20 @@ export class FusionArtifactStore {
496
631
  `${prefix}.response.partial.${input.responseKind}`,
497
632
  input.partialResponse,
498
633
  );
634
+ if (input.outputRecovery !== undefined) validateOutputRecovery(input.outputRecovery);
635
+ const outputRecoveryRef =
636
+ input.outputRecovery === undefined
637
+ ? undefined
638
+ : await this.writeArtifact(
639
+ oversizedResponseName(prefix, input.responseKind),
640
+ input.outputRecovery.original_text,
641
+ );
499
642
  await this.updateManifest((manifest) => {
500
643
  const record: FusionAttemptArtifactRecord = {
501
644
  stage: input.stage,
502
645
  attempt: input.attempt,
503
646
  status: input.status,
647
+ child_created: input.childCreated,
504
648
  prompt_path: promptRef.path,
505
649
  events_path: eventsRef.path,
506
650
  stderr_path: stderrRef.path,
@@ -508,6 +652,9 @@ export class FusionArtifactStore {
508
652
  error: input.error,
509
653
  };
510
654
  if (partialResponseRef !== undefined) record.partial_response_path = partialResponseRef.path;
655
+ if (outputRecoveryRef !== undefined && input.outputRecovery !== undefined) {
656
+ record.output_recovery = outputRecoveryRecord(input.outputRecovery, outputRecoveryRef.path);
657
+ }
511
658
  if (input.provider !== undefined) record.provider = input.provider;
512
659
  if (input.model !== undefined) record.model = input.model;
513
660
  if (input.qualifiedId !== undefined) record.qualifiedId = input.qualifiedId;
@@ -23,6 +23,20 @@ import {
23
23
  type AnonymousFusionCandidate,
24
24
  } from './prompts.js';
25
25
  import { FUSION_REASON_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
26
+ import {
27
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
28
+ FUSION_DIAGNOSTICS_MAX_BYTES,
29
+ FUSION_EVALUATION_MAX_OUTPUT_BYTES,
30
+ FUSION_MERGE_MAX_OUTPUT_BYTES,
31
+ } from './output-contract.js';
32
+ export {
33
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
34
+ FUSION_DIAGNOSTICS_MAX_BYTES,
35
+ FUSION_EVALUATION_MAX_OUTPUT_BYTES,
36
+ FUSION_MERGE_MAX_OUTPUT_BYTES,
37
+ assertChildOutputWithinContract,
38
+ fusionOutputContractBytes,
39
+ } from './output-contract.js';
26
40
  import {
27
41
  FUSION_BUDGET_PLAN_SCHEMA_VERSION,
28
42
  FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
@@ -54,11 +68,6 @@ import {
54
68
 
55
69
  export const FUSION_CALIBRATED_BYTES_PER_TOKEN = TOKEN_BUDGET_FAMILY_CALIBRATIONS;
56
70
 
57
- export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
58
- export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
59
- export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
60
- export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
61
-
62
71
  const FUSION_OUTPUT_RESERVE_RATE_X100 = 200;
63
72
  export const FUSION_UTILIZATION_WARNING_THRESHOLD_BASIS_POINTS = 8000;
64
73
  const BASIS_POINTS_DENOMINATOR = 10_000;
@@ -243,22 +252,6 @@ export function fusionTokenUpperBound(utf8Bytes: number): number {
243
252
  }).tokens;
244
253
  }
245
254
 
246
- export function fusionOutputContractBytes(stage: FusionStage): number {
247
- if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
248
- if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
249
- return FUSION_MERGE_MAX_OUTPUT_BYTES;
250
- }
251
-
252
- export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
253
- const bytes = Buffer.byteLength(JSON.stringify(text), 'utf8');
254
- const allowed = fusionOutputContractBytes(stage);
255
- if (bytes <= allowed) return;
256
- throw new FusionError(
257
- `fusion ${stage} response is ${String(bytes)} JSON-rendered bytes, exceeding the ${String(allowed)}-byte output contract for that stage; the response is preserved in the run artifacts and is not forwarded or truncated`,
258
- { code: 'child_output_cap', stage, childCreated: true },
259
- );
260
- }
261
-
262
255
  function utf8Bytes(value: string): number {
263
256
  return Buffer.byteLength(value, 'utf8');
264
257
  }
@@ -563,6 +556,13 @@ function entryLabel(entry: FusionStageBudgetPlanEntry): string {
563
556
  return `${entry.budget_stage}${slot}${conditional}`;
564
557
  }
565
558
 
559
+ function blockingChildLabel(entry: FusionStageBudgetPlanEntry): string {
560
+ if (entry.budget_stage === 'candidate') return `candidate-${String(entry.slot ?? 1)}`;
561
+ if (entry.budget_stage === 'evaluation_repair') return 'evaluator-repair';
562
+ if (entry.budget_stage === 'evaluation') return 'evaluator';
563
+ return 'merger';
564
+ }
565
+
566
566
  function formatTable(entries: readonly FusionStageBudgetPlanEntry[]): string {
567
567
  const lines = entries.map(
568
568
  (entry) =>
@@ -983,10 +983,10 @@ export class FusionBudget {
983
983
  ? ' Dominant byte class is dense ASCII/low-whitespace content; the whitespace gate is a heuristic token-density proxy, not a bound.'
984
984
  : ` Dominant byte class is ${dominantByteClass}.`;
985
985
  const message =
986
- `Fusion prompt budget exceeded by ${checkText} before child creation. Primary blocking stage: ${entryLabel(primary)} on route ${primary.route.qualified_id}. ` +
986
+ `Fusion prompt budget exceeded by ${checkText} before ${blockingChildLabel(primary)} child creation. Primary blocking stage: ${entryLabel(primary)} on route ${primary.route.qualified_id}. ` +
987
987
  `Forecast ${String(primary.input_utf8_bytes)} UTF-8 bytes (<= ${String(primary.input_only_input_tokens_upper_bound)} input tokens) against ${String(primary.allowed_input_tokens)} allowed input tokens, over by ${String(Math.max(0, tokensOver))} tokens. ` +
988
988
  `Estimator: ${rateText}.${routeWarning}${dominantText} ` +
989
- `No child was created. Nothing was clipped, dropped, or substituted. Artifact directory: ${artifactDir}.\n` +
989
+ `The ${blockingChildLabel(primary)} child was not created. Nothing was clipped, dropped, or substituted. Artifact directory: ${artifactDir}.\n` +
990
990
  `Per-stage forecast table:\n${formatTable(plan.stages)}\n` +
991
991
  `Primary blocker byte composition: ${compositionText}.\n` +
992
992
  `Additional blockers: ${additional.length === 0 ? 'none' : additional}.\n` +
@@ -1,14 +1,19 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import type { Usage } from '@earendil-works/pi-ai';
3
3
  import type { FusionClaudeCacheObservation } from './claude-cache.js';
4
+ import {
5
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
6
+ fusionJsonRenderedTextBytes,
7
+ } from './output-contract.js';
4
8
 
5
9
  export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
6
- 'pi-background-tasks.fusion-child-result.v3' as const;
10
+ 'pi-background-tasks.fusion-child-result.v4' as const;
7
11
  export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
8
12
  export const FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION =
9
- 'pi-background-tasks.fusion-child-settlement.v2' as const;
13
+ 'pi-background-tasks.fusion-child-settlement.v3' as const;
10
14
  export const FUSION_CHILD_SETTLEMENT_PREFIX = '\u001ePI_FUSION_CHILD_SETTLEMENT ';
11
15
  export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
16
+ export const FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV = 'PI_FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH';
12
17
  export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
13
18
  export const FUSION_SOURCE_POLICY_PATH_ENV = 'PI_FUSION_SOURCE_POLICY_PATH';
14
19
  export const FUSION_SOURCE_POLICY_SHA256_ENV = 'PI_FUSION_SOURCE_POLICY_SHA256';
@@ -63,6 +68,14 @@ export interface FusionChildTextBlockMetadata {
63
68
 
64
69
  export type FusionChildResultUsageMetadata = Usage;
65
70
 
71
+ export type FusionChildOutputRecoveryRole = 'none' | 'oversized_original' | 'replacement';
72
+
73
+ export interface FusionChildOutputContractMetadata {
74
+ json_rendered_bytes: number;
75
+ candidate_limit_bytes: number | null;
76
+ recovery_role: FusionChildOutputRecoveryRole;
77
+ }
78
+
66
79
  export interface FusionChildResultMetadata {
67
80
  schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
68
81
  provider: string;
@@ -72,6 +85,7 @@ export interface FusionChildResultMetadata {
72
85
  text_sha256: string;
73
86
  usage: FusionChildResultUsageMetadata;
74
87
  cache_observation: FusionClaudeCacheObservation;
88
+ output_contract: FusionChildOutputContractMetadata;
75
89
  }
76
90
 
77
91
  export type FusionChildSettlementFailureReason =
@@ -79,7 +93,8 @@ export type FusionChildSettlementFailureReason =
79
93
  | 'final_not_stop'
80
94
  | 'invalid_non_final'
81
95
  | 'runtime_guard'
82
- | 'cache_observation';
96
+ | 'cache_observation'
97
+ | 'output_recovery';
83
98
 
84
99
  export interface FusionChildSettlementRecord {
85
100
  schema_version: typeof FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION;
@@ -89,6 +104,7 @@ export interface FusionChildSettlementRecord {
89
104
  final_record_index: number | null;
90
105
  final_text_sha256: string | null;
91
106
  recovered_error_ordinals: number[];
107
+ recovered_output_cap_ordinals: number[];
92
108
  failure_reason: FusionChildSettlementFailureReason | null;
93
109
  }
94
110
 
@@ -126,7 +142,60 @@ export function isRecoverableFusionChildErrorRecord(record: FusionChildResultMet
126
142
  record.stop_reason === 'error' &&
127
143
  record.text_blocks.length === 0 &&
128
144
  record.text_sha256 === protocolSha256(Buffer.alloc(0)) &&
129
- hasZeroUsage(record)
145
+ hasZeroUsage(record) &&
146
+ record.output_contract.recovery_role === 'none'
147
+ );
148
+ }
149
+
150
+ function isOversizedOriginal(record: FusionChildResultMetadata): boolean {
151
+ const output = record.output_contract;
152
+ return (
153
+ output.recovery_role === 'oversized_original' &&
154
+ output.candidate_limit_bytes === FUSION_CANDIDATE_MAX_OUTPUT_BYTES &&
155
+ output.json_rendered_bytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES &&
156
+ record.stop_reason === 'stop'
157
+ );
158
+ }
159
+
160
+ function outputRecoveryProtocolInvalid(records: readonly FusionChildResultMetadata[]): boolean {
161
+ const originals = records.flatMap((record, ordinal) =>
162
+ record.output_contract.recovery_role === 'oversized_original' ? [ordinal] : [],
163
+ );
164
+ const replacements = records.flatMap((record, ordinal) =>
165
+ record.output_contract.recovery_role === 'replacement' ? [ordinal] : [],
166
+ );
167
+ for (const record of records) {
168
+ const output = record.output_contract;
169
+ if (
170
+ output.candidate_limit_bytes !== null &&
171
+ output.candidate_limit_bytes !== FUSION_CANDIDATE_MAX_OUTPUT_BYTES
172
+ ) {
173
+ return true;
174
+ }
175
+ if (output.recovery_role !== 'none' && output.candidate_limit_bytes === null) return true;
176
+ if (output.recovery_role === 'oversized_original' && !isOversizedOriginal(record)) return true;
177
+ }
178
+ if (originals.length === 0 && replacements.length === 0) return false;
179
+ if (originals.length !== 1 || replacements.length !== 1) return true;
180
+ const original = originals[0];
181
+ const replacement = replacements[0];
182
+ return (
183
+ original === undefined ||
184
+ replacement === undefined ||
185
+ original !== records.length - 2 ||
186
+ replacement !== records.length - 1
187
+ );
188
+ }
189
+
190
+ function finalCandidateOutputExceedsContract(
191
+ records: readonly FusionChildResultMetadata[],
192
+ ): boolean {
193
+ const final = records.at(-1);
194
+ if (final === undefined) return false;
195
+ const output = final.output_contract;
196
+ return (
197
+ output.candidate_limit_bytes === FUSION_CANDIDATE_MAX_OUTPUT_BYTES &&
198
+ output.json_rendered_bytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES
130
199
  );
131
200
  }
132
201
 
@@ -134,24 +203,36 @@ export function buildFusionChildSettlement(
134
203
  records: readonly FusionChildResultMetadata[],
135
204
  runtimeGuardFailed = false,
136
205
  cacheObservationFailed = false,
206
+ outputRecoveryFailed = false,
137
207
  ): FusionChildSettlementRecord {
138
208
  const finalRecordIndex = records.length === 0 ? null : records.length - 1;
139
209
  const final = records.at(-1);
140
210
  const recoveredErrorOrdinals = records.flatMap((record, ordinal) =>
141
211
  ordinal < records.length - 1 && isRecoverableFusionChildErrorRecord(record) ? [ordinal] : [],
142
212
  );
213
+ const recoveredOutputCapOrdinals = records.flatMap((record, ordinal) =>
214
+ ordinal < records.length - 1 && isOversizedOriginal(record) ? [ordinal] : [],
215
+ );
216
+ const invalidRecovery = outputRecoveryProtocolInvalid(records);
143
217
  const invalidNonFinal = records.some(
144
218
  (record, ordinal) =>
145
219
  ordinal < records.length - 1 &&
146
220
  record.stop_reason !== 'toolUse' &&
147
- !isRecoverableFusionChildErrorRecord(record),
221
+ !isRecoverableFusionChildErrorRecord(record) &&
222
+ !isOversizedOriginal(record),
148
223
  );
149
224
  let failureReason: FusionChildSettlementFailureReason | null = null;
150
225
  if (runtimeGuardFailed) failureReason = 'runtime_guard';
151
226
  else if (cacheObservationFailed) failureReason = 'cache_observation';
152
227
  else if (final === undefined) failureReason = 'no_records';
153
228
  else if (final.stop_reason !== 'stop') failureReason = 'final_not_stop';
154
- else if (invalidNonFinal) failureReason = 'invalid_non_final';
229
+ else if (
230
+ outputRecoveryFailed ||
231
+ invalidRecovery ||
232
+ finalCandidateOutputExceedsContract(records)
233
+ ) {
234
+ failureReason = 'output_recovery';
235
+ } else if (invalidNonFinal) failureReason = 'invalid_non_final';
155
236
  return {
156
237
  schema_version: FUSION_CHILD_SETTLEMENT_SCHEMA_VERSION,
157
238
  status: failureReason === null ? 'complete' : 'failed',
@@ -160,6 +241,7 @@ export function buildFusionChildSettlement(
160
241
  final_record_index: finalRecordIndex,
161
242
  final_text_sha256: final?.text_sha256 ?? null,
162
243
  recovered_error_ordinals: recoveredErrorOrdinals,
244
+ recovered_output_cap_ordinals: recoveredOutputCapOrdinals,
163
245
  failure_reason: failureReason,
164
246
  };
165
247
  }
@@ -173,15 +255,33 @@ export function buildFusionChildResultMetadata(
173
255
  usage: Usage;
174
256
  },
175
257
  cacheObservation: FusionClaudeCacheObservation,
258
+ outputContract: {
259
+ candidateLimitBytes: number | null;
260
+ recoveryRole: FusionChildOutputRecoveryRole;
261
+ } = { candidateLimitBytes: null, recoveryRole: 'none' },
176
262
  ): FusionChildResultMetadata {
263
+ if (
264
+ outputContract.candidateLimitBytes !== null &&
265
+ outputContract.candidateLimitBytes !== FUSION_CANDIDATE_MAX_OUTPUT_BYTES
266
+ ) {
267
+ throw new Error('fusion child candidate output limit does not match the shared contract');
268
+ }
269
+ if (outputContract.recoveryRole !== 'none' && outputContract.candidateLimitBytes === null) {
270
+ throw new Error('fusion child output recovery role requires the candidate output contract');
271
+ }
177
272
  const textBlocks = message.content.flatMap((part) =>
178
273
  part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
179
274
  );
275
+ const text = textBlocks.join('');
180
276
  const usage: FusionChildResultUsageMetadata = {
181
277
  input: message.usage.input,
182
278
  output: message.usage.output,
183
279
  cacheRead: message.usage.cacheRead,
184
280
  cacheWrite: message.usage.cacheWrite,
281
+ ...(message.usage.cacheWrite1h === undefined
282
+ ? {}
283
+ : { cacheWrite1h: message.usage.cacheWrite1h }),
284
+ ...(message.usage.reasoning === undefined ? {} : { reasoning: message.usage.reasoning }),
185
285
  totalTokens: message.usage.totalTokens,
186
286
  cost: {
187
287
  input: message.usage.cost.input,
@@ -196,12 +296,17 @@ export function buildFusionChildResultMetadata(
196
296
  provider: message.provider,
197
297
  model: message.model,
198
298
  stop_reason: message.stopReason,
199
- text_blocks: textBlocks.map((text) => ({
200
- utf8_bytes: Buffer.byteLength(text, 'utf8'),
201
- sha256: protocolSha256(text),
299
+ text_blocks: textBlocks.map((blockText) => ({
300
+ utf8_bytes: Buffer.byteLength(blockText, 'utf8'),
301
+ sha256: protocolSha256(blockText),
202
302
  })),
203
- text_sha256: protocolSha256(textBlocks.join('')),
303
+ text_sha256: protocolSha256(text),
204
304
  usage,
205
305
  cache_observation: cacheObservation,
306
+ output_contract: {
307
+ json_rendered_bytes: fusionJsonRenderedTextBytes(text),
308
+ candidate_limit_bytes: outputContract.candidateLimitBytes,
309
+ recovery_role: outputContract.recoveryRole,
310
+ },
206
311
  };
207
312
  }
@@ -5,6 +5,7 @@ export const FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION =
5
5
  export const FUSION_CLAUDE_CACHE_RETENTION_ENV = 'PI_CACHE_RETENTION';
6
6
  export const FUSION_CLAUDE_CACHE_DEFAULT_RETENTION = 'long' as const;
7
7
  export const FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT = 4;
8
+ export const FUSION_CLAUDE_PROMPT_CACHING_SCOPE_BETA = 'prompt-caching-scope-2026-01-05' as const;
8
9
 
9
10
  export type FusionClaudeCacheRetention = 'none' | 'short' | 'long';
10
11
  export type FusionClaudeCachePolicySource =
@@ -63,6 +64,26 @@ export function resolveFusionClaudeCachePolicy(env: Readonly<NodeJS.ProcessEnv>
63
64
  };
64
65
  }
65
66
 
67
+ export function applyFusionClaudePromptCachingScopeHeader(
68
+ headers: Record<string, string | null>,
69
+ ): boolean {
70
+ const matchingKey = Object.keys(headers).find((key) => key.toLowerCase() === 'anthropic-beta');
71
+ const existing = matchingKey === undefined ? undefined : headers[matchingKey];
72
+ const values =
73
+ typeof existing === 'string'
74
+ ? existing
75
+ .split(',')
76
+ .map((value) => value.trim())
77
+ .filter((value) => value.length > 0)
78
+ : [];
79
+ if (!values.includes(FUSION_CLAUDE_PROMPT_CACHING_SCOPE_BETA)) {
80
+ values.push(FUSION_CLAUDE_PROMPT_CACHING_SCOPE_BETA);
81
+ }
82
+ const targetKey = matchingKey ?? 'anthropic-beta';
83
+ headers[targetKey] = values.join(',');
84
+ return true;
85
+ }
86
+
66
87
  function validateCacheControl(value: unknown): JsonObject {
67
88
  if (!isRecord(value)) {
68
89
  throw new Error('Fusion Claude cache_control must be an object');