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,7 +1,8 @@
1
1
  import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto';
2
2
  import { canonicalJson } from '../attested-pi-run.js';
3
3
  import { parseJsonText } from '../common.js';
4
- import { FUSION_BUDGET_POLICY, FusionBudget, assertChildOutputWithinContract } from './budget.js';
4
+ import { FUSION_BUDGET_POLICY, FusionBudget } from './budget.js';
5
+ import { assertChildOutputWithinContract } from './output-contract.js';
5
6
  import {
6
7
  FusionArtifactStore,
7
8
  type CreateFusionArtifactStoreOptions,
@@ -39,7 +40,9 @@ import {
39
40
  FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
40
41
  FusionError,
41
42
  addFusionUsage,
43
+ cloneFusionUsage,
42
44
  createEmptyFusionUsage,
45
+ type FusionArtifactManifest,
43
46
  type FusionCalibrationViolation,
44
47
  type FusionCapability,
45
48
  type FusionCanonicalInputV3,
@@ -50,6 +53,7 @@ import {
50
53
  type FusionEvaluationV1,
51
54
  type FusionModelConfigV1,
52
55
  type FusionProgressEvent,
56
+ type FusionRunProgress,
53
57
  type FusionRunResult,
54
58
  type FusionSource,
55
59
  type FusionStage,
@@ -162,6 +166,7 @@ function asFusionError(error: unknown, artifactDir: string, messageOverride?: st
162
166
  if (error.slot !== undefined) details.slot = error.slot;
163
167
  if (error.attempt !== undefined) details.attempt = error.attempt;
164
168
  if (error.budget !== undefined) details.budget = error.budget;
169
+ if (error.runProgress !== undefined) details.runProgress = error.runProgress;
165
170
  return new FusionError(messageOverride ?? error.message, details);
166
171
  }
167
172
  return new FusionError(messageOverride ?? errorText(error), {
@@ -171,6 +176,103 @@ function asFusionError(error: unknown, artifactDir: string, messageOverride?: st
171
176
  });
172
177
  }
173
178
 
179
+ function fusionStageProgress(
180
+ manifest: FusionArtifactManifest,
181
+ stage: FusionStage,
182
+ ): FusionRunProgress['candidates'] {
183
+ const attempts = manifest.attempts.filter((attempt) => attempt.stage === stage);
184
+ const created = attempts.filter((attempt) => attempt.child_created).length;
185
+ const completed = attempts.filter(
186
+ (attempt) => attempt.child_created && attempt.status === 'completed',
187
+ ).length;
188
+ const failed = attempts.filter(
189
+ (attempt) => attempt.child_created && attempt.status === 'failed',
190
+ ).length;
191
+ const cancelled = attempts.filter(
192
+ (attempt) => attempt.child_created && attempt.status === 'cancelled',
193
+ ).length;
194
+ const completedByState =
195
+ stage === 'candidate'
196
+ ? completed >= 3
197
+ : stage === 'evaluation'
198
+ ? manifest.artifacts['evaluation.json'] !== undefined ||
199
+ manifest.state === 'evaluation_complete' ||
200
+ manifest.state === 'merging' ||
201
+ manifest.state === 'completed'
202
+ : manifest.artifacts['merged.md'] !== undefined || manifest.state === 'completed';
203
+ const progress: FusionRunProgress['candidates'] = {
204
+ status: completedByState ? 'completed' : created === 0 ? 'not_started' : 'incomplete',
205
+ attempts_recorded: attempts.length,
206
+ children_created: created,
207
+ children_completed: completed,
208
+ children_failed: failed,
209
+ children_cancelled: cancelled,
210
+ };
211
+ if (stage === 'candidate') {
212
+ const createdSlots = new Set(
213
+ attempts.flatMap((attempt) =>
214
+ attempt.child_created && attempt.slot !== undefined ? [attempt.slot] : [],
215
+ ),
216
+ );
217
+ progress.not_started_slots = 3 - createdSlots.size;
218
+ }
219
+ return progress;
220
+ }
221
+
222
+ export function buildFusionRunProgress(manifest: FusionArtifactManifest): FusionRunProgress {
223
+ return {
224
+ manifest_state: manifest.state,
225
+ candidates: fusionStageProgress(manifest, 'candidate'),
226
+ evaluation: fusionStageProgress(manifest, 'evaluation'),
227
+ merge: fusionStageProgress(manifest, 'merge'),
228
+ usage_so_far: cloneFusionUsage(manifest.usage),
229
+ };
230
+ }
231
+
232
+ function formatFusionRunStage(name: string, stage: FusionRunProgress['candidates']): string {
233
+ const notStarted =
234
+ stage.not_started_slots === undefined
235
+ ? ''
236
+ : `, ${String(stage.not_started_slots)} slot(s) not started`;
237
+ return `${name}=${stage.status} (${String(stage.children_created)} created, ${String(stage.children_completed)} completed, ${String(stage.children_failed)} failed, ${String(stage.children_cancelled)} cancelled${notStarted})`;
238
+ }
239
+
240
+ export function formatFusionRunProgress(progress: FusionRunProgress): string {
241
+ const usage = progress.usage_so_far;
242
+ const optionalUsage = [
243
+ usage.cacheWrite1h === undefined ? undefined : `cacheWrite1h=${String(usage.cacheWrite1h)}`,
244
+ usage.reasoning === undefined ? undefined : `reasoning=${String(usage.reasoning)}`,
245
+ ].filter((value): value is string => value !== undefined);
246
+ const optionalText = optionalUsage.length === 0 ? '' : `, ${optionalUsage.join(', ')}`;
247
+ return (
248
+ `Run progress from durable attempts: ${formatFusionRunStage('candidates', progress.candidates)}; ` +
249
+ `${formatFusionRunStage('evaluation', progress.evaluation)}; ` +
250
+ `${formatFusionRunStage('merge', progress.merge)}. ` +
251
+ `Usage so far: input=${String(usage.input)}, output=${String(usage.output)}, cacheRead=${String(usage.cacheRead)}, cacheWrite=${String(usage.cacheWrite)}${optionalText}, totalTokens=${String(usage.totalTokens)}, ` +
252
+ `cost.input=${String(usage.cost.input)}, cost.output=${String(usage.cost.output)}, cost.cacheRead=${String(usage.cost.cacheRead)}, cost.cacheWrite=${String(usage.cost.cacheWrite)}, cost.total=${String(usage.cost.total)}.`
253
+ );
254
+ }
255
+
256
+ function withRunProgress(
257
+ error: unknown,
258
+ artifactDir: string,
259
+ progress: FusionRunProgress,
260
+ ): FusionError {
261
+ const base = asFusionError(error, artifactDir);
262
+ const details: FusionErrorDetails = {
263
+ code: base.code,
264
+ artifactDir,
265
+ transient: base.transient,
266
+ childCreated: base.childCreated,
267
+ runProgress: progress,
268
+ };
269
+ if (base.stage !== undefined) details.stage = base.stage;
270
+ if (base.slot !== undefined) details.slot = base.slot;
271
+ if (base.attempt !== undefined) details.attempt = base.attempt;
272
+ if (base.budget !== undefined) details.budget = base.budget;
273
+ return new FusionError(`${base.message}\n${formatFusionRunProgress(progress)}`, details);
274
+ }
275
+
174
276
  function withTerminalArtifactFailure(
175
277
  error: unknown,
176
278
  artifactDir: string,
@@ -201,7 +303,9 @@ function recordFailureInput(
201
303
  error: error.message,
202
304
  status: error.code === 'child_cancelled' ? 'cancelled' : 'failed',
203
305
  responseKind,
306
+ childCreated: error.childCreated,
204
307
  usage: error.usage,
308
+ ...(error.outputRecovery === undefined ? {} : { outputRecovery: error.outputRecovery }),
205
309
  };
206
310
  if (slot !== undefined) base.slot = slot;
207
311
  if (error.provider !== undefined) base.provider = error.provider;
@@ -221,6 +325,7 @@ function recordFailureInput(
221
325
  status:
222
326
  error instanceof FusionError && error.code === 'child_cancelled' ? 'cancelled' : 'failed',
223
327
  responseKind,
328
+ childCreated: error instanceof FusionError ? error.childCreated : false,
224
329
  };
225
330
  if (slot !== undefined) base.slot = slot;
226
331
  return base;
@@ -245,6 +350,7 @@ function childOptions(
245
350
  slot?: CandidateSlot,
246
351
  toolCallLogPath?: string,
247
352
  sourcePolicy?: { path: string; sha256: string },
353
+ candidateOutputRecoveryPath?: string,
248
354
  ): RunPiChildOptions {
249
355
  const out: RunPiChildOptions = {
250
356
  stage,
@@ -259,6 +365,8 @@ function childOptions(
259
365
  if (slot !== undefined) out.slot = slot;
260
366
  if (toolCallLogPath !== undefined) out.toolCallLogPath = toolCallLogPath;
261
367
  if (sourcePolicy !== undefined) out.sourcePolicy = sourcePolicy;
368
+ if (candidateOutputRecoveryPath !== undefined)
369
+ out.candidateOutputRecoveryPath = candidateOutputRecoveryPath;
262
370
  return out;
263
371
  }
264
372
 
@@ -848,10 +956,15 @@ export class FusionOrchestrator {
848
956
  const cancelled =
849
957
  input.signal?.aborted === true ||
850
958
  (error instanceof FusionError && error.code === 'child_cancelled');
851
- const message = errorText(error);
959
+ let terminalError: FusionError;
852
960
  try {
853
961
  await store.setUsage(usage);
854
- await store.writeError(cancelled ? 'cancelled' : 'failed', message);
962
+ terminalError = withRunProgress(
963
+ error,
964
+ store.artifactDir,
965
+ buildFusionRunProgress(store.snapshot()),
966
+ );
967
+ await store.writeError(cancelled ? 'cancelled' : 'failed', terminalError.message);
855
968
  } catch (artifactError) {
856
969
  throw withTerminalArtifactFailure(error, store.artifactDir, artifactError);
857
970
  }
@@ -860,17 +973,17 @@ export class FusionOrchestrator {
860
973
  type: 'cancelled',
861
974
  runId: store.runId,
862
975
  artifactDir: store.artifactDir,
863
- reason: message,
976
+ reason: terminalError.message,
864
977
  });
865
978
  } else {
866
979
  input.onProgress?.({
867
980
  type: 'failed',
868
981
  runId: store.runId,
869
982
  artifactDir: store.artifactDir,
870
- error: message,
983
+ error: terminalError.message,
871
984
  });
872
985
  }
873
- throw asFusionError(error, store.artifactDir);
986
+ throw terminalError;
874
987
  }
875
988
  }
876
989
 
@@ -934,12 +1047,12 @@ export class FusionOrchestrator {
934
1047
  result,
935
1048
  slot,
936
1049
  );
937
- // The response is durable before the contract check, so an oversized
938
- // answer is preserved as evidence rather than lost.
939
- assertChildOutputWithinContract('candidate', result.text);
940
- completed += 1;
1050
+ // The response and its consumed usage are durable before the contract
1051
+ // check, so an oversized answer is preserved and accounted rather than lost.
941
1052
  addFusionUsage(usage, result.usage);
942
1053
  await store.setUsage(usage);
1054
+ assertChildOutputWithinContract('candidate', result.text);
1055
+ completed += 1;
943
1056
  input.onProgress?.({ type: 'candidate_completed', slot, completed, total: 3 });
944
1057
  return { slot, result };
945
1058
  });
@@ -1135,6 +1248,10 @@ export class FusionOrchestrator {
1135
1248
  : undefined;
1136
1249
  const sourcePolicy =
1137
1250
  capability === 'research' ? store.sourcePolicyLaunchReference() : undefined;
1251
+ const candidateOutputRecoveryPath =
1252
+ stage === 'candidate' && slot !== undefined
1253
+ ? store.childOutputRecoveryPath(slot, logicalAttempt, responseKind)
1254
+ : undefined;
1138
1255
  try {
1139
1256
  return await this.childRunner(
1140
1257
  childOptions(
@@ -1149,6 +1266,7 @@ export class FusionOrchestrator {
1149
1266
  slot,
1150
1267
  toolCallLogPath,
1151
1268
  sourcePolicy,
1269
+ candidateOutputRecoveryPath,
1152
1270
  ),
1153
1271
  );
1154
1272
  } catch (error) {
@@ -0,0 +1,34 @@
1
+ import { FusionError, type FusionStage } from './types.js';
2
+
3
+ /** Hard output contracts are measured over the JSON rendering embedded downstream. */
4
+ export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
5
+ export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
6
+ export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
7
+ export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
8
+
9
+ const FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY =
10
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES.toLocaleString('en-US');
11
+
12
+ export const FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION = `Your complete response must be at most ${FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY} JSON-rendered UTF-8 bytes. If the requested scope cannot fit, prioritize the most important findings and explicitly state limitations.`;
13
+
14
+ export const FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT = `Compress and restructure only your immediately previous answer so the complete replacement is at most ${FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY} JSON-rendered UTF-8 bytes. Do not investigate again, do not use tools, and do not add new evidence. Preserve the most important findings and evidence already present, state material limitations, obey the original output format, and output only the replacement answer.`;
15
+
16
+ export function fusionJsonRenderedTextBytes(text: string): number {
17
+ return Buffer.byteLength(JSON.stringify(text), 'utf8');
18
+ }
19
+
20
+ export function fusionOutputContractBytes(stage: FusionStage): number {
21
+ if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
22
+ if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
23
+ return FUSION_MERGE_MAX_OUTPUT_BYTES;
24
+ }
25
+
26
+ export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
27
+ const bytes = fusionJsonRenderedTextBytes(text);
28
+ const allowed = fusionOutputContractBytes(stage);
29
+ if (bytes <= allowed) return;
30
+ throw new FusionError(
31
+ `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`,
32
+ { code: 'child_output_cap', stage, childCreated: true },
33
+ );
34
+ }