pi-background-tasks 1.0.4 → 1.0.7

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.
@@ -158,6 +158,17 @@ function requireContextWindow(model: Model<Api>, label: string): number {
158
158
  return Math.floor(value);
159
159
  }
160
160
 
161
+ function requireMaxOutputTokens(model: Model<Api>, label: string): number {
162
+ const value = model.maxTokens;
163
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
164
+ throw new FusionError(`${label} has no positive maximum output token capacity`, {
165
+ code: 'model_unavailable',
166
+ childCreated: false,
167
+ });
168
+ }
169
+ return Math.floor(value);
170
+ }
171
+
161
172
  function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
162
173
  const out = new Map<string, Model<Api>>();
163
174
  for (const model of models) out.set(qualifiedModelKey(model), model);
@@ -305,6 +316,7 @@ function resolveSelection(
305
316
  qualifiedId,
306
317
  thinkingLevel,
307
318
  contextWindow: requireContextWindow(available, slotLabel),
319
+ maxOutputTokens: requireMaxOutputTokens(available, slotLabel),
308
320
  };
309
321
  }
310
322
  const model = availableByKey.get(selection);
@@ -323,6 +335,7 @@ function resolveSelection(
323
335
  qualifiedId: selection,
324
336
  thinkingLevel,
325
337
  contextWindow: requireContextWindow(model, slotLabel),
338
+ maxOutputTokens: requireMaxOutputTokens(model, slotLabel),
326
339
  };
327
340
  }
328
341
 
@@ -525,6 +525,67 @@ export interface ParsedFusionValidationCandidateReport {
525
525
  limitations: readonly string[];
526
526
  }
527
527
 
528
+ export type FusionValidationCandidateNormalization =
529
+ | 'markdown_json_fence'
530
+ | 'prose_then_markdown_json_fence';
531
+
532
+ export interface RecoveredFusionValidationCandidateReport {
533
+ report: ParsedFusionValidationCandidateReport;
534
+ /** Bare JSON forwarded to the evaluator after explicit, audited recovery. */
535
+ response: string;
536
+ normalization: FusionValidationCandidateNormalization;
537
+ }
538
+
539
+ /**
540
+ * Recognize exactly one complete Markdown JSON fence, optionally preceded by a
541
+ * short prose preamble. This is deliberately narrower than generic substring
542
+ * extraction: trailing prose, nested fences, unlabelled fences, and oversized
543
+ * preambles remain contract failures.
544
+ */
545
+ function fencedValidationCandidateJson(text: string): {
546
+ payload: string;
547
+ normalization: FusionValidationCandidateNormalization;
548
+ } | undefined {
549
+ const trimmed = text.trim();
550
+ const openingPattern = /```json[ \t]*\r?\n/giu;
551
+ const openings = [...trimmed.matchAll(openingPattern)];
552
+ if (openings.length !== 1) return undefined;
553
+ const opening = openings[0];
554
+ if (opening === undefined) return undefined;
555
+ const headerEnd = opening.index + opening[0].length;
556
+ const closing = trimmed.indexOf('```', headerEnd);
557
+ if (closing < 0 || trimmed.slice(closing + 3).includes('```')) return undefined;
558
+ if (trimmed.slice(closing + 3).trim().length > 0) return undefined;
559
+ const preamble = trimmed.slice(0, opening.index).trim();
560
+ if (Buffer.byteLength(preamble, 'utf8') > 2_000 || preamble.includes('```')) return undefined;
561
+ const payload = trimmed.slice(headerEnd, closing).trim();
562
+ if (payload.length === 0 || payload.includes('```')) return undefined;
563
+ return {
564
+ payload,
565
+ normalization: preamble.length === 0
566
+ ? 'markdown_json_fence'
567
+ : 'prose_then_markdown_json_fence',
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Defensive recovery for the one observed contract violation shape. Callers
573
+ * must persist/surface the returned normalization; this function intentionally
574
+ * does not make the strict parser permissive.
575
+ */
576
+ export function recoverFencedFusionValidationCandidateReport(
577
+ text: string,
578
+ candidateId: FusionCandidateId,
579
+ ): RecoveredFusionValidationCandidateReport | undefined {
580
+ const recovered = fencedValidationCandidateJson(text);
581
+ if (recovered === undefined) return undefined;
582
+ return {
583
+ report: parseFusionValidationCandidateReport(recovered.payload, candidateId),
584
+ response: recovered.payload,
585
+ normalization: recovered.normalization,
586
+ };
587
+ }
588
+
528
589
  export function parseFusionValidationCandidateReport(text: string, candidateId: FusionCandidateId): ParsedFusionValidationCandidateReport {
529
590
  let parsed: unknown;
530
591
  try {
@@ -1,4 +1,4 @@
1
- import { randomBytes as nodeRandomBytes } from 'node:crypto';
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
4
  import { FUSION_BUDGET_POLICY, FusionBudget, assertChildOutputWithinContract } from './budget.js';
@@ -11,6 +11,7 @@ import {
11
11
  boundedEvaluationErrors,
12
12
  formatEvaluationErrors,
13
13
  parseFusionValidationCandidateReport,
14
+ recoverFencedFusionValidationCandidateReport,
14
15
  renderValidatedFusionValidationReport,
15
16
  validateFusionEvaluation,
16
17
  validateFusionFindingAccounting,
@@ -35,6 +36,7 @@ import {
35
36
  FUSION_INPUT_SCHEMA_VERSION,
36
37
  FUSION_NO_TOOLS_CAPABILITY,
37
38
  FUSION_RESULT_SCHEMA_VERSION,
39
+ FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
38
40
  FusionError,
39
41
  addFusionUsage,
40
42
  createEmptyFusionUsage,
@@ -169,6 +171,7 @@ function recordFailureInput(
169
171
  stage: FusionStage,
170
172
  slot: CandidateSlot | undefined,
171
173
  attempt: number,
174
+ systemPrompt: string,
172
175
  prompt: string,
173
176
  responseKind: 'md' | 'txt',
174
177
  ): RecordFusionFailedAttemptInput {
@@ -176,6 +179,7 @@ function recordFailureInput(
176
179
  const base: RecordFusionFailedAttemptInput = {
177
180
  stage,
178
181
  attempt,
182
+ systemPrompt,
179
183
  prompt,
180
184
  events: error.events,
181
185
  partialResponse: error.response,
@@ -194,6 +198,7 @@ function recordFailureInput(
194
198
  const base: RecordFusionFailedAttemptInput = {
195
199
  stage,
196
200
  attempt,
201
+ systemPrompt,
197
202
  prompt,
198
203
  events: Buffer.alloc(0),
199
204
  partialResponse: Buffer.alloc(0),
@@ -371,22 +376,123 @@ function anonymousCandidates(
371
376
  }
372
377
 
373
378
  interface ValidationSourceData {
379
+ candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate];
374
380
  findings: readonly FusionValidationFindingRecord[];
375
381
  verified: readonly string[];
376
382
  limitations: readonly string[];
377
383
  }
378
384
 
379
- function validationSourceData(candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate]): ValidationSourceData {
385
+ function sha256Text(value: string): string {
386
+ return createHash('sha256').update(value, 'utf8').digest('hex');
387
+ }
388
+
389
+ function boundedContractError(error: unknown): string {
390
+ const value = errorText(error);
391
+ return value.length <= 1_000 ? value : `${value.slice(0, 999)}…`;
392
+ }
393
+
394
+ /**
395
+ * Enforce the validation-candidate contract without making the shared JSON
396
+ * parser permissive. A single, tightly recognized fenced response is recovered
397
+ * with a durable warning. One irrecoverable minority report is represented as
398
+ * an explicit limitation; two or more still fail the workflow loudly.
399
+ */
400
+ async function prepareValidationSourceData(
401
+ candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate],
402
+ anonymousMap: Record<FusionCandidateId, CandidateSlot>,
403
+ store: FusionArtifactStore,
404
+ ): Promise<ValidationSourceData> {
405
+ const prepared = candidates.map((candidate) => ({ ...candidate })) as [
406
+ AnonymousFusionCandidate,
407
+ AnonymousFusionCandidate,
408
+ AnonymousFusionCandidate,
409
+ ];
380
410
  const findings: FusionValidationFindingRecord[] = [];
381
411
  const verified: string[] = [];
382
412
  const limitations: string[] = [];
383
- for (const candidate of candidates) {
384
- const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
385
- findings.push(...report.findings);
386
- verified.push(...report.verified);
413
+ let normalizationCount = 0;
414
+ const failures: Array<{ candidate: AnonymousFusionCandidate; error: string }> = [];
415
+
416
+ for (const candidate of prepared) {
417
+ try {
418
+ const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
419
+ findings.push(...report.findings);
420
+ verified.push(...report.verified);
421
+ limitations.push(...report.limitations);
422
+ continue;
423
+ } catch (strictError) {
424
+ try {
425
+ const recovered = recoverFencedFusionValidationCandidateReport(
426
+ candidate.response,
427
+ candidate.candidate_id,
428
+ );
429
+ if (recovered === undefined) throw strictError;
430
+ await store.recordValidationCandidateContractEvent({
431
+ candidateId: candidate.candidate_id,
432
+ slot: anonymousMap[candidate.candidate_id],
433
+ status: 'normalized',
434
+ detail: {
435
+ normalization: recovered.normalization,
436
+ original_sha256: sha256Text(candidate.response),
437
+ forwarded_sha256: sha256Text(recovered.response),
438
+ warning: 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
439
+ },
440
+ });
441
+ candidate.response = recovered.response;
442
+ findings.push(...recovered.report.findings);
443
+ verified.push(...recovered.report.verified);
444
+ limitations.push(...recovered.report.limitations);
445
+ normalizationCount += 1;
446
+ continue;
447
+ } catch (recoveryError) {
448
+ failures.push({
449
+ candidate,
450
+ error: boundedContractError(recoveryError === strictError ? strictError : recoveryError),
451
+ });
452
+ }
453
+ }
454
+ }
455
+
456
+ if (normalizationCount > 0) {
457
+ limitations.push(
458
+ `${String(normalizationCount)} validation report${normalizationCount === 1 ? '' : 's'} required audited removal of a Markdown JSON wrapper; JSON content was unchanged.`,
459
+ );
460
+ }
461
+
462
+ for (const failure of failures) {
463
+ await store.recordValidationCandidateContractEvent({
464
+ candidateId: failure.candidate.candidate_id,
465
+ slot: anonymousMap[failure.candidate.candidate_id],
466
+ status: 'dropped',
467
+ detail: {
468
+ response_sha256: sha256Text(failure.candidate.response),
469
+ error: failure.error,
470
+ warning: 'Candidate output could not be parsed under the strict or fenced-JSON contract.',
471
+ },
472
+ });
473
+ }
474
+ if (failures.length > 1) {
475
+ throw new FusionError(
476
+ `fusion_validate cannot continue: ${String(failures.length)} of 3 candidate reports violated the structured-output contract`,
477
+ { code: 'evaluation_invalid', stage: 'candidate' },
478
+ );
479
+ }
480
+ const failure = failures[0];
481
+ if (failure !== undefined) {
482
+ const synthetic = canonicalJson({
483
+ schema_version: FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
484
+ findings: [],
485
+ verified: [],
486
+ limitations: [
487
+ 'This validation report could not be parsed after strict contract checks; no findings or verification claims from it were included.',
488
+ ],
489
+ });
490
+ failure.candidate.response = synthetic;
491
+ const report = parseFusionValidationCandidateReport(synthetic, failure.candidate.candidate_id);
387
492
  limitations.push(...report.limitations);
388
493
  }
389
- return { findings, verified, limitations };
494
+
495
+ return { candidates: prepared, findings, verified, limitations };
390
496
  }
391
497
 
392
498
  function validateEvaluationAccountsForSourceFindings(
@@ -558,11 +664,16 @@ export class FusionOrchestrator {
558
664
  input.onProgress?.({ type: 'state', state: 'candidates_complete' });
559
665
 
560
666
  const shuffled = anonymousCandidates(candidateResults, shuffledSlots(this.randomBytes));
561
- const validationData = profile.id === 'validate' ? validationSourceData(shuffled.candidates) : undefined;
667
+ // Persist the blind mapping before workflow-specific contract parsing
668
+ // so a failed validation remains attributable to its durable slot artifact.
562
669
  await store.setAnonymousMap(shuffled.map);
670
+ const validationData = profile.id === 'validate'
671
+ ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
672
+ : undefined;
673
+ const evaluationCandidates = validationData?.candidates ?? shuffled.candidates;
563
674
  const blindInput = buildBlindEvaluationInput(
564
675
  input.canonicalInput,
565
- shuffled.candidates,
676
+ evaluationCandidates,
566
677
  validationData?.findings,
567
678
  );
568
679
  await store.writeBlindCandidates(buildEvaluationPrompt(blindInput));
@@ -585,7 +696,7 @@ export class FusionOrchestrator {
585
696
 
586
697
  await store.transition('merging');
587
698
  input.onProgress?.({ type: 'state', state: 'merging' });
588
- const mergeInput = buildMergeInput(input.canonicalInput, shuffled.candidates, evaluation);
699
+ const mergeInput = buildMergeInput(input.canonicalInput, evaluationCandidates, evaluation);
589
700
  const mergePrompt = buildMergePrompt(mergeInput);
590
701
  budget.assertStagePrompt('merge', profile.mergerSystemPrompt, mergePrompt);
591
702
  input.onProgress?.({ type: 'merge_started' });
@@ -604,7 +715,7 @@ export class FusionOrchestrator {
604
715
  'md',
605
716
  );
606
717
  addFusionUsage(usage, merged.usage);
607
- await store.recordChildAttempt({ result: merged, prompt: mergePrompt, responseKind: 'md' });
718
+ await store.recordChildAttempt({ result: merged, systemPrompt: profile.mergerSystemPrompt, prompt: mergePrompt, responseKind: 'md' });
608
719
  await this.recordCalibrationObservation(
609
720
  input,
610
721
  store,
@@ -729,9 +840,9 @@ export class FusionOrchestrator {
729
840
  controller.signal,
730
841
  candidateCapability,
731
842
  slot,
732
- 'md',
843
+ profile.id === 'validate' ? 'txt' : 'md',
733
844
  ).then(async (result) => {
734
- await store.recordChildAttempt({ result, prompt, responseKind: 'md' });
845
+ await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: profile.id === 'validate' ? 'txt' : 'md' });
735
846
  await this.recordCalibrationObservation(
736
847
  input,
737
848
  store,
@@ -866,7 +977,7 @@ export class FusionOrchestrator {
866
977
  attempt,
867
978
  );
868
979
  addFusionUsage(usage, result.usage);
869
- await store.recordChildAttempt({ result, prompt, responseKind: 'txt' });
980
+ await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: 'txt' });
870
981
  await this.recordCalibrationObservation(
871
982
  input,
872
983
  store,
@@ -966,7 +1077,7 @@ export class FusionOrchestrator {
966
1077
  if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;
967
1078
  addFailedChildUsage(usage, error);
968
1079
  await store.recordFailedAttempt(
969
- recordFailureInput(error, stage, slot, logicalAttempt, userPrompt, responseKind),
1080
+ recordFailureInput(error, stage, slot, logicalAttempt, systemPrompt, userPrompt, responseKind),
970
1081
  );
971
1082
  await store.setUsage(usage);
972
1083
  throw error;