pi-background-tasks 2.0.0 → 2.1.2

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.
@@ -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';
@@ -17,6 +17,7 @@ import {
17
17
  type FusionBudgetPlanV1,
18
18
  type FusionCalibrationViolation,
19
19
  type FusionCandidateId,
20
+ type FusionCandidateOutputRecovery,
20
21
  type FusionCapability,
21
22
  type FusionContextOmissionLedgerV2,
22
23
  type FusionChildRunResult,
@@ -32,6 +33,10 @@ import {
32
33
  type ResolvedFusionModels,
33
34
  } from './types.js';
34
35
  import { fusionWorkflowProfile, type FusionWorkflowProfile } from './workflows.js';
36
+ import {
37
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
38
+ fusionJsonRenderedTextBytes,
39
+ } from './output-contract.js';
35
40
 
36
41
  /**
37
42
  * Run ids are prefixed by workflow so an artifact directory is self-describing.
@@ -135,7 +140,9 @@ export interface RecordFusionFailedAttemptInput {
135
140
  stderr: Buffer;
136
141
  error: string;
137
142
  status: 'failed' | 'cancelled';
143
+ childCreated: boolean;
138
144
  responseKind: 'md' | 'txt';
145
+ outputRecovery?: FusionCandidateOutputRecovery;
139
146
  provider?: string;
140
147
  model?: string;
141
148
  qualifiedId?: string;
@@ -250,6 +257,59 @@ function calibrationViolationName(prefix: string): string {
250
257
  return `${prefix}.calibration-violation.json`;
251
258
  }
252
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
+
253
313
  function artifactRefSha256Hex(value: string): string {
254
314
  const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value;
255
315
  if (!/^[0-9a-f]{64}$/u.test(hex)) {
@@ -353,6 +413,12 @@ export class FusionArtifactStore {
353
413
  return this.artifactPath(`${attemptPrefix(stage, slot, attempt)}.tool-calls.jsonl`);
354
414
  }
355
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
+
356
422
  snapshot(): FusionArtifactManifest {
357
423
  return publicManifest(this.manifest);
358
424
  }
@@ -482,11 +548,22 @@ export class FusionArtifactStore {
482
548
  input.result.toolCallTrace === undefined
483
549
  ? undefined
484
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
+ );
485
561
  await this.updateManifest((manifest) => {
486
562
  const record: FusionAttemptArtifactRecord = {
487
563
  stage: input.result.stage,
488
564
  attempt: input.result.attempt,
489
565
  status: 'completed',
566
+ child_created: true,
490
567
  prompt_path: promptRef.path,
491
568
  events_path: eventsRef.path,
492
569
  stderr_path: stderrRef.path,
@@ -500,6 +577,12 @@ export class FusionArtifactStore {
500
577
  record.tool_calls_path = toolCallsRef.path;
501
578
  record.tool_calls = { ...input.result.toolCallTrace.summary };
502
579
  }
580
+ if (outputRecoveryRef !== undefined && input.result.outputRecovery !== undefined) {
581
+ record.output_recovery = outputRecoveryRecord(
582
+ input.result.outputRecovery,
583
+ outputRecoveryRef.path,
584
+ );
585
+ }
503
586
  if (input.result.slot !== undefined) record.slot = input.result.slot;
504
587
  manifest.attempts.push(record);
505
588
  });
@@ -548,11 +631,20 @@ export class FusionArtifactStore {
548
631
  `${prefix}.response.partial.${input.responseKind}`,
549
632
  input.partialResponse,
550
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
+ );
551
642
  await this.updateManifest((manifest) => {
552
643
  const record: FusionAttemptArtifactRecord = {
553
644
  stage: input.stage,
554
645
  attempt: input.attempt,
555
646
  status: input.status,
647
+ child_created: input.childCreated,
556
648
  prompt_path: promptRef.path,
557
649
  events_path: eventsRef.path,
558
650
  stderr_path: stderrRef.path,
@@ -560,6 +652,9 @@ export class FusionArtifactStore {
560
652
  error: input.error,
561
653
  };
562
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
+ }
563
658
  if (input.provider !== undefined) record.provider = input.provider;
564
659
  if (input.model !== undefined) record.model = input.model;
565
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');
@@ -5,6 +5,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
5
  import type { Api, Model } from '@earendil-works/pi-ai';
6
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
7
  import { replaceFileDurable } from '../durable-fs.js';
8
+ import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from './anthropic-attribution.js';
8
9
  import {
9
10
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
10
11
  FusionError,
@@ -158,6 +159,13 @@ function requireContextWindow(model: Model<Api>, label: string): number {
158
159
  return Math.floor(value);
159
160
  }
160
161
 
162
+ function transportContextWindow(model: Model<Api>, label: string): number {
163
+ const advertised = requireContextWindow(model, label);
164
+ return model.provider === 'anthropic'
165
+ ? Math.min(advertised, CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW)
166
+ : advertised;
167
+ }
168
+
161
169
  function requireMaxOutputTokens(model: Model<Api>, label: string): number {
162
170
  const value = model.maxTokens;
163
171
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
@@ -315,7 +323,7 @@ function resolveSelection(
315
323
  model: available.id,
316
324
  qualifiedId,
317
325
  thinkingLevel,
318
- contextWindow: requireContextWindow(available, slotLabel),
326
+ contextWindow: transportContextWindow(available, slotLabel),
319
327
  maxOutputTokens: requireMaxOutputTokens(available, slotLabel),
320
328
  };
321
329
  }
@@ -334,7 +342,7 @@ function resolveSelection(
334
342
  model: model.id,
335
343
  qualifiedId: selection,
336
344
  thinkingLevel,
337
- contextWindow: requireContextWindow(model, slotLabel),
345
+ contextWindow: transportContextWindow(model, slotLabel),
338
346
  maxOutputTokens: requireMaxOutputTokens(model, slotLabel),
339
347
  };
340
348
  }