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,5 @@
1
1
  import { canonicalJson } from '../attested-pi-run.js';
2
+ import { FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION } from './output-contract.js';
2
3
  import {
3
4
  FUSION_EVALUATION_SCHEMA_VERSION,
4
5
  FUSION_VALIDATE_CAPABILITY,
@@ -33,6 +34,8 @@ ${FUSION_CANONICAL_INPUT_GUIDE}
33
34
 
34
35
  Produce the strongest direct answer you can for the request using that context.
35
36
 
37
+ ${FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION}
38
+
36
39
  Do not invent process metadata. Do not mention provider names, model names, slots, or hidden workflow details. Do not specialize the answer; each child receives the same instruction. Output only the answer text.`;
37
40
 
38
41
  export const FUSION_INSPECT_CANONICAL_INPUT_GUIDE = `The JSON input contains only workflow, cwd, request, and clean-task context. context.kind is "clean_task".
@@ -47,6 +50,8 @@ ${FUSION_INSPECT_CANONICAL_INPUT_GUIDE}
47
50
 
48
51
  Produce the strongest direct answer you can for the request using that context.
49
52
 
53
+ ${FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION}
54
+
50
55
  Do not invent process metadata. Do not mention provider names, model names, slots, or hidden workflow details. Do not specialize the answer; each child receives the same instruction. Output only the answer text.`;
51
56
 
52
57
  export const FUSION_RESEARCH_CANONICAL_INPUT_GUIDE = `The JSON input contains only workflow, cwd, request, and clean-task context. Research uses context.kind "clean_task" with declared_sources: the only initial public URLs fusion_web_fetch may initiate. Redirects are followed only by the fetcher after public-address checks; fusion_web_fetch is targeted URL fetch, not search.
@@ -61,6 +66,8 @@ ${FUSION_RESEARCH_CANONICAL_INPUT_GUIDE}
61
66
 
62
67
  Produce the strongest direct answer you can for the request using that context.
63
68
 
69
+ ${FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION}
70
+
64
71
  Do not invent process metadata. Do not mention provider names, model names, slots, or hidden workflow details. Do not specialize the answer; each child receives the same instruction. Output only the answer text.`;
65
72
 
66
73
  export function fusionCandidateSystemPrompt(capability: FusionCapability): string {
@@ -198,6 +205,8 @@ Stay in scope. Validate what the request names. Do not propose unrelated refacto
198
205
 
199
206
  Close with what you verified and what you could not cover.
200
207
 
208
+ ${FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION}
209
+
201
210
  Do not invent process metadata. Do not mention provider names, model names, slots, or hidden workflow details. Do not specialize the report; each child receives the same instruction. Output only the required JSON.`;
202
211
 
203
212
  /**
@@ -301,7 +310,8 @@ export function buildBlindEvaluationInput(
301
310
  canonical_input: canonicalInput,
302
311
  candidates,
303
312
  };
304
- if (validationSourceFindings !== undefined) input.validation_source_findings = validationSourceFindings;
313
+ if (validationSourceFindings !== undefined)
314
+ input.validation_source_findings = validationSourceFindings;
305
315
  return input;
306
316
  }
307
317
 
@@ -61,7 +61,16 @@ function usage(value: unknown, artifactDir: string): FusionUsage {
61
61
  if (!isRecord(value)) fail('details.usage must be an object', artifactDir);
62
62
  assertOnlyKeys(
63
63
  value,
64
- ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'cost'],
64
+ [
65
+ 'input',
66
+ 'output',
67
+ 'cacheRead',
68
+ 'cacheWrite',
69
+ 'cacheWrite1h',
70
+ 'reasoning',
71
+ 'totalTokens',
72
+ 'cost',
73
+ ],
65
74
  'details.usage',
66
75
  artifactDir,
67
76
  );
@@ -78,11 +87,29 @@ function usage(value: unknown, artifactDir: string): FusionUsage {
78
87
  fail(`${label} is invalid`, artifactDir);
79
88
  return entry;
80
89
  };
90
+ const output = finiteNonnegative(value['output'], 'details.usage.output');
91
+ const cacheWrite = finiteNonnegative(value['cacheWrite'], 'details.usage.cacheWrite');
92
+ const cacheWrite1h =
93
+ value['cacheWrite1h'] === undefined
94
+ ? undefined
95
+ : finiteNonnegative(value['cacheWrite1h'], 'details.usage.cacheWrite1h');
96
+ const reasoning =
97
+ value['reasoning'] === undefined
98
+ ? undefined
99
+ : finiteNonnegative(value['reasoning'], 'details.usage.reasoning');
100
+ if (cacheWrite1h !== undefined && cacheWrite1h > cacheWrite) {
101
+ fail('details.usage.cacheWrite1h must not exceed cacheWrite', artifactDir);
102
+ }
103
+ if (reasoning !== undefined && reasoning > output) {
104
+ fail('details.usage.reasoning must not exceed output', artifactDir);
105
+ }
81
106
  return {
82
107
  input: finiteNonnegative(value['input'], 'details.usage.input'),
83
- output: finiteNonnegative(value['output'], 'details.usage.output'),
108
+ output,
84
109
  cacheRead: finiteNonnegative(value['cacheRead'], 'details.usage.cacheRead'),
85
- cacheWrite: finiteNonnegative(value['cacheWrite'], 'details.usage.cacheWrite'),
110
+ cacheWrite,
111
+ ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
112
+ ...(reasoning === undefined ? {} : { reasoning }),
86
113
  totalTokens: finiteNonnegative(value['totalTokens'], 'details.usage.totalTokens'),
87
114
  cost: {
88
115
  input: finiteNonnegative(cost['input'], 'details.usage.cost.input'),
@@ -522,6 +522,8 @@ export function cloneFusionUsage(usage: FusionUsage): FusionUsage {
522
522
  output: usage.output,
523
523
  cacheRead: usage.cacheRead,
524
524
  cacheWrite: usage.cacheWrite,
525
+ ...(usage.cacheWrite1h === undefined ? {} : { cacheWrite1h: usage.cacheWrite1h }),
526
+ ...(usage.reasoning === undefined ? {} : { reasoning: usage.reasoning }),
525
527
  totalTokens: usage.totalTokens,
526
528
  cost: {
527
529
  input: usage.cost.input,
@@ -538,6 +540,12 @@ export function addFusionUsage(target: FusionUsage, delta: FusionUsage): void {
538
540
  target.output += delta.output;
539
541
  target.cacheRead += delta.cacheRead;
540
542
  target.cacheWrite += delta.cacheWrite;
543
+ if (delta.cacheWrite1h !== undefined) {
544
+ target.cacheWrite1h = (target.cacheWrite1h ?? 0) + delta.cacheWrite1h;
545
+ }
546
+ if (delta.reasoning !== undefined) {
547
+ target.reasoning = (target.reasoning ?? 0) + delta.reasoning;
548
+ }
541
549
  target.totalTokens += delta.totalTokens;
542
550
  target.cost.input += delta.cost.input;
543
551
  target.cost.output += delta.cost.output;
@@ -546,6 +554,26 @@ export function addFusionUsage(target: FusionUsage, delta: FusionUsage): void {
546
554
  target.cost.total += delta.cost.total;
547
555
  }
548
556
 
557
+ export type FusionRunStageProgressStatus = 'not_started' | 'incomplete' | 'completed';
558
+
559
+ export interface FusionRunStageProgress {
560
+ status: FusionRunStageProgressStatus;
561
+ attempts_recorded: number;
562
+ children_created: number;
563
+ children_completed: number;
564
+ children_failed: number;
565
+ children_cancelled: number;
566
+ not_started_slots?: number | undefined;
567
+ }
568
+
569
+ export interface FusionRunProgress {
570
+ manifest_state: FusionState;
571
+ candidates: FusionRunStageProgress;
572
+ evaluation: FusionRunStageProgress;
573
+ merge: FusionRunStageProgress;
574
+ usage_so_far: FusionUsage;
575
+ }
576
+
549
577
  export interface FusionResultBudgetDetails {
550
578
  policy_id: string;
551
579
  calibration_version: string;
@@ -707,6 +735,7 @@ export interface FusionErrorDetails {
707
735
  transient?: boolean;
708
736
  childCreated?: boolean;
709
737
  budget?: FusionBudgetErrorDetail;
738
+ runProgress?: FusionRunProgress;
710
739
  }
711
740
 
712
741
  export class FusionError extends Error {
@@ -718,6 +747,7 @@ export class FusionError extends Error {
718
747
  readonly transient: boolean;
719
748
  readonly childCreated: boolean;
720
749
  readonly budget: FusionBudgetErrorDetail | undefined;
750
+ readonly runProgress: FusionRunProgress | undefined;
721
751
 
722
752
  constructor(message: string, details: FusionErrorDetails) {
723
753
  super(message);
@@ -730,6 +760,7 @@ export class FusionError extends Error {
730
760
  this.transient = details.transient ?? false;
731
761
  this.childCreated = details.childCreated ?? true;
732
762
  this.budget = details.budget;
763
+ this.runProgress = details.runProgress;
733
764
  }
734
765
  }
735
766
 
@@ -772,6 +803,18 @@ export interface FusionToolCallTrace {
772
803
  summary: FusionToolCallLogSummary;
773
804
  }
774
805
 
806
+ export interface FusionCandidateOutputRecovery {
807
+ kind: 'same_session_compression';
808
+ limit_bytes: number;
809
+ original_record_index: number;
810
+ replacement_record_index: number | null;
811
+ original_json_rendered_bytes: number;
812
+ replacement_json_rendered_bytes: number | null;
813
+ original_text_sha256: string;
814
+ original_text: string;
815
+ status: 'completed' | 'failed';
816
+ }
817
+
775
818
  export interface FusionChildRunResult {
776
819
  stage: FusionStage;
777
820
  slot?: 1 | 2 | 3;
@@ -786,6 +829,7 @@ export interface FusionChildRunResult {
786
829
  firstRequestUsage?: FusionUsage;
787
830
  /** Number of provider requests represented by aggregate usage. */
788
831
  providerRequestCount?: number;
832
+ outputRecovery?: FusionCandidateOutputRecovery;
789
833
  events: Buffer;
790
834
  stderr: Buffer;
791
835
  exitCode: number;
@@ -793,11 +837,24 @@ export interface FusionChildRunResult {
793
837
  toolCallTrace?: FusionToolCallTrace;
794
838
  }
795
839
 
840
+ export interface FusionAttemptOutputRecoveryRecord {
841
+ kind: 'same_session_compression';
842
+ status: 'completed' | 'failed';
843
+ limit_bytes: number;
844
+ original_response_path: string;
845
+ original_record_index: number;
846
+ replacement_record_index: number | null;
847
+ original_json_rendered_bytes: number;
848
+ replacement_json_rendered_bytes: number | null;
849
+ original_text_sha256: string;
850
+ }
851
+
796
852
  export interface FusionAttemptArtifactRecord {
797
853
  stage: FusionStage;
798
854
  slot?: 1 | 2 | 3;
799
855
  attempt: number;
800
856
  status: 'completed' | 'failed' | 'cancelled';
857
+ child_created: boolean;
801
858
  prompt_path: string;
802
859
  events_path?: string;
803
860
  stderr_path?: string;
@@ -805,6 +862,7 @@ export interface FusionAttemptArtifactRecord {
805
862
  partial_response_path?: string;
806
863
  tool_calls_path?: string;
807
864
  tool_calls?: FusionToolCallLogSummary;
865
+ output_recovery?: FusionAttemptOutputRecoveryRecord;
808
866
  provider?: string;
809
867
  model?: string;
810
868
  qualifiedId?: string;
@@ -36,18 +36,17 @@ import {
36
36
  * - enforcing turn and tool-call limits;
37
37
  * - committing exactly one result package atomically.
38
38
  *
39
- * Measured Pi 0.83 behaviour this design accounts for (see
40
- * `tests/scripted-provider/pi-hook-contract.test.ts`):
39
+ * Measured behavior across the supported Pi lines (see
40
+ * `tests/scripted-provider/pi-hook-contract.test.ts` and `scripts/test-compat.ts`):
41
41
  *
42
- * - Throwing from a `context` handler does NOT stop the provider call. Pi
43
- * catches the exception and dispatches anyway. A throw is therefore never used
44
- * as a barrier here.
45
- * - `ctx.abort()` does not skip the provider call site, but the call receives an
46
- * already-aborted signal and the run terminates. That is the barrier used.
47
- * - Because neither mechanism is a hard admission gate on its own, the guard
48
- * ALSO replaces the offending content in the returned message set. Even a
49
- * provider that ignored the aborted signal could not transmit the content,
50
- * because the content is no longer there.
42
+ * - Throwing from a `context` handler does NOT stop dispatch. Pi catches the
43
+ * exception and continues, so a throw is never used as a barrier here.
44
+ * - `ctx.abort()` blocks transport in both supported modes: Pi 0.81.1-0.83.0
45
+ * invoke the provider with an already-aborted signal, while Pi 0.84.0 skips
46
+ * the provider entry point during signal-aware auth resolution.
47
+ * - The guard ALSO replaces the offending content in the returned message set.
48
+ * Even a non-conforming provider could not transmit the content because the
49
+ * content is no longer there.
51
50
  */
52
51
 
53
52
  const SPILL_DIRNAME = 'spill';
@@ -471,8 +470,8 @@ export default function delegateChildExtension(pi: ExtensionAPI): void {
471
470
  if (verdict.withinBudget) return undefined;
472
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`;
473
472
  latch('provider_context_budget_exhausted', message);
474
- // Barrier one: terminate the run. Measured on Pi 0.83, this hands the
475
- // provider call an already-aborted signal and stops further turns.
473
+ // Barrier one: terminate the run. Depending on the supported Pi line,
474
+ // this skips provider dispatch or hands it an already-aborted signal.
476
475
  ctx.abort();
477
476
  // Barrier two: remove the content itself, so the request could not carry it
478
477
  // even if a provider ignored the aborted signal. Retaining only the first
@@ -8,7 +8,7 @@ import {
8
8
  readFileSync,
9
9
  writeSync,
10
10
  } from 'node:fs';
11
- import { dirname } from 'node:path';
11
+ import { dirname, isAbsolute } from 'node:path';
12
12
  import { parseJsonText } from './core/common.js';
13
13
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
14
14
  import { Type, type Static } from 'typebox';
@@ -32,11 +32,13 @@ import {
32
32
  parseFusionSourcePolicy,
33
33
  } from './core/fusion/source-policy.js';
34
34
  import {
35
+ applyFusionClaudePromptCachingScopeHeader,
35
36
  nonAnthropicFusionCacheObservation,
36
37
  normalizeFusionClaudeCachePayload,
37
38
  type FusionClaudeCacheObservation,
38
39
  } from './core/fusion/claude-cache.js';
39
40
  import {
41
+ FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV,
40
42
  FUSION_CHILD_MAX_PROVIDER_REQUESTS,
41
43
  FUSION_CHILD_MAX_TOOL_CALLS,
42
44
  FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
@@ -59,12 +61,19 @@ import {
59
61
  type FusionRuntimeGuardCode,
60
62
  type FusionRuntimeGuardRecord,
61
63
  } from './core/fusion/child-protocol.js';
64
+ import {
65
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
66
+ FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT,
67
+ fusionJsonRenderedTextBytes,
68
+ } from './core/fusion/output-contract.js';
62
69
 
63
70
  export {
64
71
  FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT,
65
72
  FUSION_CLAUDE_CACHE_DEFAULT_RETENTION,
66
73
  FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION,
67
74
  FUSION_CLAUDE_CACHE_RETENTION_ENV,
75
+ FUSION_CLAUDE_PROMPT_CACHING_SCOPE_BETA,
76
+ applyFusionClaudePromptCachingScopeHeader,
68
77
  nonAnthropicFusionCacheObservation,
69
78
  normalizeFusionClaudeCachePayload,
70
79
  resolveFusionClaudeCachePolicy,
@@ -75,6 +84,7 @@ export {
75
84
  } from './core/fusion/claude-cache.js';
76
85
 
77
86
  export {
87
+ FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV,
78
88
  FUSION_CHILD_MAX_PROVIDER_REQUESTS,
79
89
  FUSION_CHILD_MAX_TOOL_CALLS,
80
90
  FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
@@ -103,6 +113,12 @@ export {
103
113
  type FusionRuntimeGuardRecord,
104
114
  } from './core/fusion/child-protocol.js';
105
115
 
116
+ export {
117
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
118
+ FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT,
119
+ fusionJsonRenderedTextBytes,
120
+ } from './core/fusion/output-contract.js';
121
+
106
122
  const FUSION_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
107
123
 
108
124
  const FusionWebFetchParams = Type.Object(
@@ -253,6 +269,24 @@ function createToolCallLog(path: string): void {
253
269
  fsyncParentDirectorySync(path);
254
270
  }
255
271
 
272
+ function createCandidateOutputRecoveryArtifact(path: string, text: string): void {
273
+ if (!isAbsolute(path)) {
274
+ throw new Error(`${FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV} must be an absolute path`);
275
+ }
276
+ const bytes = Buffer.from(text, 'utf8');
277
+ withRegularFileDescriptorSync(
278
+ path,
279
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
280
+ 0o600,
281
+ 'fusion oversized candidate response',
282
+ (fd) => {
283
+ writeAllSync(fd, bytes, 'fusion oversized candidate response');
284
+ fsyncSync(fd);
285
+ },
286
+ );
287
+ fsyncParentDirectorySync(path);
288
+ }
289
+
256
290
  function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): void {
257
291
  // The log is an audit trail, not a payload copy: raw tool arguments/results may
258
292
  // contain secrets, so only byte counts and SHA-256 digests are persisted.
@@ -670,6 +704,15 @@ function fusionWebFetchResultText(result: Awaited<ReturnType<typeof fusionWebFet
670
704
  */
671
705
  export default function fusionChildExtension(pi: ExtensionAPI): void {
672
706
  const toolCallLogPath = process.env[FUSION_TOOL_CALL_LOG_PATH_ENV];
707
+ const candidateOutputRecoveryPath = process.env[FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV];
708
+ if (
709
+ candidateOutputRecoveryPath !== undefined &&
710
+ (candidateOutputRecoveryPath.trim().length === 0 || !isAbsolute(candidateOutputRecoveryPath))
711
+ ) {
712
+ throw new Error(
713
+ `${FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV} must be an absolute non-blank path`,
714
+ );
715
+ }
673
716
  const researchEnabled = process.env[FUSION_RESEARCH_ENABLED_ENV];
674
717
  if (researchEnabled !== undefined && researchEnabled !== '1') {
675
718
  throw new Error(`${FUSION_RESEARCH_ENABLED_ENV} must be unset or exactly 1`);
@@ -684,10 +727,17 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
684
727
  let providerRequestCount = 0;
685
728
  let toolCallCount = 0;
686
729
  let runtimeGuardFailed = false;
730
+ let outputRecoveryFailed = false;
731
+ let outputRecoveryPhase: 'eligible' | 'queued' | 'finished' = 'eligible';
687
732
  let settlementPublished = false;
688
733
  const childResultRecords: FusionChildResultMetadata[] = [];
689
734
  let pendingCacheObservation: FusionClaudeCacheObservation | undefined;
690
735
 
736
+ pi.on('before_provider_headers', (event, ctx) => {
737
+ if (ctx.model?.provider !== 'anthropic') return;
738
+ applyFusionClaudePromptCachingScopeHeader(event.headers);
739
+ });
740
+
691
741
  pi.on('before_provider_request', async (event, ctx) => {
692
742
  providerRequestCount += 1;
693
743
  if (runtimeGuardFailed) {
@@ -820,6 +870,15 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
820
870
  pi.on('tool_call', async (event, ctx) => {
821
871
  try {
822
872
  requireOpen('tool_call');
873
+ if (outputRecoveryPhase === 'queued') {
874
+ outputRecoveryFailed = true;
875
+ latchAuditProcessFailure();
876
+ ctx.abort();
877
+ return {
878
+ block: true,
879
+ reason: 'fusion candidate output compression is a no-tool continuation',
880
+ };
881
+ }
823
882
  if (runtimeGuardFailed) {
824
883
  ctx.abort();
825
884
  return { block: true, reason: 'fusion child runtime guard already refused the run' };
@@ -978,13 +1037,68 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
978
1037
  if (event.message.role !== 'assistant') return;
979
1038
  const cacheObservation = pendingCacheObservation;
980
1039
  if (cacheObservation === undefined) {
1040
+ // Authentication and other pre-transport failures can produce an assistant
1041
+ // error without ever reaching before_provider_request. Preserve Pi's original
1042
+ // provider diagnostic and let settlement report no_records; emitting a second
1043
+ // cache-policy error would mask the actionable failure. A successful result
1044
+ // without an observation remains a hard invariant violation.
1045
+ if (event.message.stopReason === 'error') return;
981
1046
  latchAuditProcessFailure();
982
1047
  throw new Error('fusion child assistant result has no matching cache-policy observation');
983
1048
  }
984
1049
  pendingCacheObservation = undefined;
985
- const record = buildFusionChildResultMetadata(event.message, cacheObservation);
1050
+ const text = event.message.content
1051
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
1052
+ .join('');
1053
+ const renderedBytes = fusionJsonRenderedTextBytes(text);
1054
+ const isReplacement = outputRecoveryPhase === 'queued';
1055
+ const shouldRecover =
1056
+ candidateOutputRecoveryPath !== undefined &&
1057
+ outputRecoveryPhase === 'eligible' &&
1058
+ event.message.stopReason === 'stop' &&
1059
+ renderedBytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
1060
+ const recoveryRole = isReplacement
1061
+ ? 'replacement'
1062
+ : shouldRecover
1063
+ ? 'oversized_original'
1064
+ : 'none';
1065
+ const record = buildFusionChildResultMetadata(event.message, cacheObservation, {
1066
+ candidateLimitBytes:
1067
+ candidateOutputRecoveryPath === undefined ? null : FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
1068
+ recoveryRole,
1069
+ });
1070
+ if (shouldRecover) {
1071
+ try {
1072
+ createCandidateOutputRecoveryArtifact(candidateOutputRecoveryPath, text);
1073
+ } catch (error) {
1074
+ outputRecoveryFailed = true;
1075
+ latchAuditProcessFailure();
1076
+ throw error;
1077
+ }
1078
+ }
986
1079
  await writeMetadata(record);
987
1080
  childResultRecords.push(record);
1081
+ if (shouldRecover) {
1082
+ outputRecoveryPhase = 'queued';
1083
+ try {
1084
+ pi.setActiveTools([]);
1085
+ pi.sendUserMessage(FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT, {
1086
+ deliverAs: 'followUp',
1087
+ });
1088
+ } catch (error) {
1089
+ outputRecoveryFailed = true;
1090
+ latchAuditProcessFailure();
1091
+ throw error;
1092
+ }
1093
+ return;
1094
+ }
1095
+ if (isReplacement) {
1096
+ outputRecoveryPhase = 'finished';
1097
+ if (renderedBytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES) {
1098
+ outputRecoveryFailed = true;
1099
+ latchAuditProcessFailure();
1100
+ }
1101
+ }
988
1102
  });
989
1103
  pi.on('agent_settled', async (_event, ctx) => {
990
1104
  if (settlementPublished) {
@@ -1001,6 +1115,7 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
1001
1115
  childResultRecords,
1002
1116
  runtimeGuardFailed,
1003
1117
  cacheObservationFailed,
1118
+ outputRecoveryFailed,
1004
1119
  );
1005
1120
  if (settlement.status !== 'complete') latchAuditProcessFailure();
1006
1121
  await writeSettlement(settlement);