pi-background-tasks 0.7.7 → 1.0.3

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 (72) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
  2. package/PUBLISHING.md +43 -29
  3. package/README.md +234 -385
  4. package/TESTING.md +15 -9
  5. package/TEST_PLAN.md +46 -13
  6. package/docs/INDEX.md +157 -0
  7. package/docs/api/eventbus-v1.md +166 -0
  8. package/docs/assets/architecture.svg +78 -0
  9. package/docs/assets/footer-dock.svg +47 -0
  10. package/docs/assets/logo.svg +49 -0
  11. package/docs/attestations.json +189 -0
  12. package/docs/choose-a-workflow.md +98 -0
  13. package/docs/commands/bg-clear.md +70 -0
  14. package/docs/commands/bg-update.md +82 -0
  15. package/docs/commands/bg.md +90 -0
  16. package/docs/commands/fusion-models.md +70 -0
  17. package/docs/commands/fusion.md +69 -0
  18. package/docs/commands/jobs.md +74 -0
  19. package/docs/commands/kill.md +82 -0
  20. package/docs/commands/logs.md +90 -0
  21. package/docs/commands/task-manager.md +109 -0
  22. package/docs/concepts/completion-delivery.md +66 -0
  23. package/docs/concepts/context-projection-and-budgeting.md +79 -0
  24. package/docs/getting-started.md +122 -0
  25. package/docs/manifest.json +1825 -0
  26. package/docs/operations/configuration.md +110 -0
  27. package/docs/operations/releasing.md +67 -0
  28. package/docs/operations/testing.md +101 -0
  29. package/docs/operations/troubleshooting.md +38 -0
  30. package/docs/read-before-edit.md +94 -0
  31. package/docs/reference/runtime-contracts.md +213 -0
  32. package/docs/reference/shortcuts-and-dock.md +70 -0
  33. package/docs/subsystems/attested-pi-runs.md +141 -0
  34. package/docs/subsystems/background-task-runtime.md +85 -0
  35. package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
  36. package/docs/subsystems/delegation.md +190 -0
  37. package/docs/subsystems/docs-freshness-gate.md +26 -0
  38. package/docs/subsystems/fusion.md +121 -0
  39. package/docs/subsystems/host-ui-and-telemetry.md +83 -0
  40. package/docs/tools/bg_delegate.md +193 -0
  41. package/docs/tools/bg_kill.md +114 -0
  42. package/docs/tools/bg_logs.md +133 -0
  43. package/docs/tools/bg_result.md +120 -0
  44. package/docs/tools/bg_run.md +168 -0
  45. package/docs/tools/bg_run_pi_attested.md +170 -0
  46. package/docs/tools/bg_status.md +111 -0
  47. package/docs/tools/fusion_investigate.md +116 -0
  48. package/docs/tools/fusion_reason.md +75 -0
  49. package/docs/tools/fusion_research.md +162 -0
  50. package/docs/tools/fusion_validate.md +206 -0
  51. package/logo.png +0 -0
  52. package/package.json +29 -6
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +6 -0
  55. package/src/core/fusion/artifacts.ts +80 -5
  56. package/src/core/fusion/budget.ts +129 -28
  57. package/src/core/fusion/child-protocol.ts +82 -0
  58. package/src/core/fusion/clean-context.ts +91 -0
  59. package/src/core/fusion/config.ts +124 -35
  60. package/src/core/fusion/context.ts +33 -6
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +274 -25
  63. package/src/core/fusion/pi-child.ts +635 -10
  64. package/src/core/fusion/prompts.ts +167 -6
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +232 -5
  67. package/src/core/fusion/web-fetch.ts +993 -0
  68. package/src/core/fusion/workflows.ts +184 -0
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +370 -54
  71. package/src/fusion-extension.ts +625 -125
  72. package/src/testing/normalize.ts +0 -22
@@ -1,4 +1,5 @@
1
1
  import { randomBytes as nodeRandomBytes } from 'node:crypto';
2
+ import { canonicalJson } from '../attested-pi-run.js';
2
3
  import { parseJsonText } from '../common.js';
3
4
  import { FUSION_BUDGET_POLICY, FusionBudget, assertChildOutputWithinContract } from './budget.js';
4
5
  import {
@@ -9,14 +10,13 @@ import {
9
10
  import {
10
11
  boundedEvaluationErrors,
11
12
  formatEvaluationErrors,
13
+ parseFusionValidationCandidateReport,
14
+ renderValidatedFusionValidationReport,
12
15
  validateFusionEvaluation,
16
+ validateFusionFindingAccounting,
13
17
  } from './evaluation.js';
14
18
  import { FusionChildRunError, runPiChild, type RunPiChildOptions } from './pi-child.js';
15
19
  import {
16
- FUSION_CANDIDATE_SYSTEM_PROMPT,
17
- FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
18
- FUSION_EVALUATOR_SYSTEM_PROMPT,
19
- FUSION_MERGER_SYSTEM_PROMPT,
20
20
  buildBlindEvaluationInput,
21
21
  buildCandidatePrompt,
22
22
  buildEvaluationPrompt,
@@ -26,11 +26,20 @@ import {
26
26
  type AnonymousFusionCandidate,
27
27
  } from './prompts.js';
28
28
  import {
29
+ assertWorkflowCapability,
30
+ fusionWorkflowProfile,
31
+ type FusionWorkflowProfile,
32
+ } from './workflows.js';
33
+ import { buildFusionSourcePolicy, sourcePolicyCanonicalBytes } from './source-policy.js';
34
+ import {
35
+ FUSION_INPUT_SCHEMA_VERSION,
36
+ FUSION_NO_TOOLS_CAPABILITY,
29
37
  FUSION_RESULT_SCHEMA_VERSION,
30
38
  FusionError,
31
39
  addFusionUsage,
32
40
  createEmptyFusionUsage,
33
41
  type FusionCalibrationViolation,
42
+ type FusionCapability,
34
43
  type FusionCanonicalInputV3,
35
44
  type FusionCandidateId,
36
45
  type FusionContextOmissionLedgerV2,
@@ -43,6 +52,7 @@ import {
43
52
  type FusionSource,
44
53
  type FusionStage,
45
54
  type FusionUsage,
55
+ type FusionValidationFindingRecord,
46
56
  type ResolvedFusionModel,
47
57
  type ResolvedFusionModels,
48
58
  } from './types.js';
@@ -59,9 +69,12 @@ export interface FusionWorkflowInput {
59
69
  sessionId?: string | undefined;
60
70
  canonicalInput: FusionCanonicalInputV3;
61
71
  canonicalInputSerialized: string;
62
- contextLedger: FusionContextOmissionLedgerV2;
72
+ contextLedger?: FusionContextOmissionLedgerV2 | undefined;
63
73
  config: FusionModelConfigV1;
64
74
  models: ResolvedFusionModels;
75
+ candidateCapability?: FusionCapability | undefined;
76
+ /** Mandatory v5 workflow profile. */
77
+ profile?: FusionWorkflowProfile | undefined;
65
78
  signal?: AbortSignal | undefined;
66
79
  onProgress?: FusionProgressSink | undefined;
67
80
  }
@@ -94,6 +107,33 @@ function errorText(error: unknown): string {
94
107
  return error instanceof Error ? error.message : String(error);
95
108
  }
96
109
 
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
112
+ }
113
+
114
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
115
+ const allowedSet = new Set(allowed);
116
+ return Object.keys(value).every((key) => allowedSet.has(key));
117
+ }
118
+
119
+ function isStrictCleanCanonicalInput(value: unknown): boolean {
120
+ if (!isRecord(value)) return false;
121
+ if (!hasOnlyKeys(value, ['schema_version', 'workflow', 'cwd', 'request', 'context'])) return false;
122
+ const request = value['request'];
123
+ if (!isRecord(request)) return false;
124
+ if (!hasOnlyKeys(request, ['source', 'authority', 'text', 'sha256'])) return false;
125
+ const context = value['context'];
126
+ if (!isRecord(context)) return false;
127
+ if (!hasOnlyKeys(context, ['kind', 'policy_id', 'declared_sources'])) return false;
128
+ if (context['kind'] !== 'clean_task') return false;
129
+ const declaredSources = context['declared_sources'];
130
+ if (!Array.isArray(declaredSources)) return false;
131
+ for (const source of declaredSources) {
132
+ if (!isRecord(source) || !hasOnlyKeys(source, ['url', 'canonical_url', 'purpose', 'sha256'])) return false;
133
+ }
134
+ return true;
135
+ }
136
+
97
137
  function asFusionError(error: unknown, artifactDir: string, messageOverride?: string): FusionError {
98
138
  if (error instanceof FusionError) {
99
139
  const details: FusionErrorDetails = {
@@ -179,25 +219,34 @@ function childOptions(
179
219
  model: ResolvedFusionModel,
180
220
  stage: FusionStage,
181
221
  attempt: number,
222
+ capability: FusionCapability,
182
223
  systemPrompt: string,
183
224
  userPrompt: string,
184
225
  signal: AbortSignal,
185
226
  slot?: CandidateSlot,
227
+ toolCallLogPath?: string,
228
+ sourcePolicy?: { path: string; sha256: string },
186
229
  ): RunPiChildOptions {
187
230
  const out: RunPiChildOptions = {
188
231
  stage,
189
232
  attempt,
190
233
  cwd: input.cwd,
191
234
  model,
235
+ capability,
192
236
  systemPrompt,
193
237
  userPrompt,
194
238
  signal,
195
239
  };
196
240
  if (slot !== undefined) out.slot = slot;
241
+ if (toolCallLogPath !== undefined) out.toolCallLogPath = toolCallLogPath;
242
+ if (sourcePolicy !== undefined) out.sourcePolicy = sourcePolicy;
197
243
  return out;
198
244
  }
199
245
 
200
- function parseEvaluationAttempt(text: string): {
246
+ function parseEvaluationAttempt(
247
+ text: string,
248
+ expectedValidationFindings: readonly FusionValidationFindingRecord[] | undefined,
249
+ ): {
201
250
  evaluation: FusionEvaluationV1 | undefined;
202
251
  errors: readonly string[];
203
252
  } {
@@ -211,8 +260,21 @@ function parseEvaluationAttempt(text: string): {
211
260
  };
212
261
  }
213
262
  const result = validateFusionEvaluation(parsed);
214
- if (result.ok) return { evaluation: result.value, errors: [] };
215
- return { evaluation: undefined, errors: result.errors };
263
+ if (!result.ok) return { evaluation: undefined, errors: result.errors };
264
+ if (
265
+ expectedValidationFindings === undefined &&
266
+ result.value.validation_accounting !== undefined
267
+ ) {
268
+ return {
269
+ evaluation: undefined,
270
+ errors: ['evaluation.validation_accounting is permitted only for fusion_validate'],
271
+ };
272
+ }
273
+ if (expectedValidationFindings !== undefined) {
274
+ const accountingErrors = validateEvaluationAccountsForSourceFindings(result.value, expectedValidationFindings);
275
+ if (accountingErrors.length > 0) return { evaluation: undefined, errors: accountingErrors };
276
+ }
277
+ return { evaluation: result.value, errors: [] };
216
278
  }
217
279
 
218
280
  function randomIndex(limit: number, randomBytes: FusionRandomBytes): number {
@@ -308,6 +370,63 @@ function anonymousCandidates(
308
370
  };
309
371
  }
310
372
 
373
+ interface ValidationSourceData {
374
+ findings: readonly FusionValidationFindingRecord[];
375
+ verified: readonly string[];
376
+ limitations: readonly string[];
377
+ }
378
+
379
+ function validationSourceData(candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate]): ValidationSourceData {
380
+ const findings: FusionValidationFindingRecord[] = [];
381
+ const verified: string[] = [];
382
+ 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);
387
+ limitations.push(...report.limitations);
388
+ }
389
+ return { findings, verified, limitations };
390
+ }
391
+
392
+ function validateEvaluationAccountsForSourceFindings(
393
+ evaluation: FusionEvaluationV1,
394
+ sourceFindings: readonly FusionValidationFindingRecord[],
395
+ ): readonly string[] {
396
+ const errors: string[] = [];
397
+ const accounting = evaluation.validation_accounting;
398
+ if (accounting === undefined) {
399
+ return ['validation evaluator output must include validation_accounting'];
400
+ }
401
+ const expected = sourceFindings.map((finding) => canonicalJson(finding)).sort();
402
+ const actual = accounting.findings.map((finding) => canonicalJson(finding)).sort();
403
+ if (expected.length !== actual.length || expected.some((value, index) => value !== actual[index])) {
404
+ errors.push('validation evaluator validation_accounting.findings must exactly equal host-assigned source findings');
405
+ }
406
+ errors.push(...validateFusionFindingAccounting(accounting));
407
+ return errors;
408
+ }
409
+
410
+
411
+ function resolveRunProfile(input: FusionWorkflowInput): FusionWorkflowProfile {
412
+ if (input.profile !== undefined) return fusionWorkflowProfile(input.profile.id);
413
+ const workflow = input.canonicalInput.workflow;
414
+ const contextKind = input.canonicalInput.context?.kind;
415
+ if (workflow !== undefined && workflow !== 'reason') {
416
+ throw new FusionError(`fusion workflow profile is required for ${workflow} runs`, {
417
+ code: 'orchestration_failed',
418
+ childCreated: false,
419
+ });
420
+ }
421
+ if (contextKind === 'clean_task') {
422
+ throw new FusionError('fusion workflow profile is required for clean-task runs', {
423
+ code: 'orchestration_failed',
424
+ childCreated: false,
425
+ });
426
+ }
427
+ return fusionWorkflowProfile('reason');
428
+ }
429
+
311
430
  export class FusionOrchestrator {
312
431
  private readonly childRunner: FusionChildRunner;
313
432
  private readonly randomBytes: FusionRandomBytes;
@@ -324,26 +443,95 @@ export class FusionOrchestrator {
324
443
  }
325
444
 
326
445
  async run(input: FusionWorkflowInput): Promise<FusionRunResult> {
446
+ if (input.canonicalInput.schema_version !== FUSION_INPUT_SCHEMA_VERSION) {
447
+ throw new FusionError('fusion orchestrator accepts only v5 canonical input', {
448
+ code: 'orchestration_failed',
449
+ childCreated: false,
450
+ });
451
+ }
452
+ const profile = resolveRunProfile(input);
453
+ const inputWorkflow = input.canonicalInput.workflow ?? profile.id;
454
+ const inputContextKind = input.canonicalInput.context?.kind ?? 'session_projection';
455
+ if (inputWorkflow !== profile.id || inputContextKind !== profile.contextKind) {
456
+ throw new FusionError(
457
+ `fusion workflow profile ${profile.id} is incompatible with canonical input workflow=${String(inputWorkflow)} context=${String(inputContextKind)}`,
458
+ { code: 'orchestration_failed', childCreated: false },
459
+ );
460
+ }
461
+ if (profile.contextKind === 'clean_task' && !isStrictCleanCanonicalInput(input.canonicalInput)) {
462
+ throw new FusionError('clean-task fusion input must not carry parent context fields and must match the strict clean canonical shape', {
463
+ code: 'orchestration_failed',
464
+ childCreated: false,
465
+ });
466
+ }
467
+ const candidateCapability = assertWorkflowCapability(profile, input.candidateCapability);
327
468
  const storeOptions: CreateFusionArtifactStoreOptions = {
328
469
  cwd: input.cwd,
470
+ profile,
329
471
  source: input.source,
330
472
  config: input.config,
331
473
  models: input.models,
474
+ capabilities: {
475
+ candidate: candidateCapability,
476
+ evaluation: FUSION_NO_TOOLS_CAPABILITY,
477
+ merge: FUSION_NO_TOOLS_CAPABILITY,
478
+ },
332
479
  };
333
480
  if (input.sessionId !== undefined) storeOptions.sessionId = input.sessionId;
334
481
  if (this.now !== undefined) storeOptions.now = this.now;
482
+ let serializedParsed: unknown;
483
+ try {
484
+ serializedParsed = parseJsonText(input.canonicalInputSerialized);
485
+ } catch (error) {
486
+ throw new FusionError(`fusion canonical input artifact is not valid JSON: ${errorText(error)}`, {
487
+ code: 'orchestration_failed',
488
+ childCreated: false,
489
+ });
490
+ }
491
+ if (canonicalJson(serializedParsed) !== canonicalJson(input.canonicalInput)) {
492
+ throw new FusionError('fusion canonical input serialized bytes do not match canonical input object', {
493
+ code: 'orchestration_failed',
494
+ childCreated: false,
495
+ });
496
+ }
335
497
  const store = await this.createArtifactStore(storeOptions);
336
498
  input.onProgress?.({ type: 'state', state: 'initializing' });
337
499
  const usage = createEmptyFusionUsage();
338
500
  const calibrationWarnings: FusionCalibrationViolation[] = [];
339
501
  try {
340
502
  await store.writeCanonicalInput(input.canonicalInputSerialized);
341
- await store.writeContextLedger(input.contextLedger);
503
+ if (inputContextKind === 'session_projection') {
504
+ if (input.contextLedger === undefined) {
505
+ throw new FusionError('session-projection fusion input requires an omission ledger artifact', {
506
+ code: 'orchestration_failed',
507
+ childCreated: false,
508
+ });
509
+ }
510
+ await store.writeContextLedger(input.contextLedger);
511
+ } else if (input.contextLedger !== undefined) {
512
+ throw new FusionError('clean-task fusion input must not carry a parent omission ledger', {
513
+ code: 'orchestration_failed',
514
+ childCreated: false,
515
+ });
516
+ }
517
+ if (profile.id === 'research') {
518
+ const cleanContext = input.canonicalInput.context;
519
+ if (cleanContext?.kind !== 'clean_task') {
520
+ throw new FusionError('research workflow requires a clean-task canonical input', {
521
+ code: 'orchestration_failed',
522
+ childCreated: false,
523
+ });
524
+ }
525
+ const policy = buildFusionSourcePolicy(input.cwd, cleanContext.declared_sources);
526
+ await store.writeSourcePolicy(sourcePolicyCanonicalBytes(policy));
527
+ }
342
528
  // Deterministic size accounting for the whole workflow, performed before
343
529
  // a single child process exists. A rejection here launches zero children.
344
530
  const budget = new FusionBudget(
345
531
  input.models,
346
- input.canonicalInput.conversation_projection.policy.id,
532
+ input.canonicalInput.context?.policy_id ?? 'fusion-session-projection-v1',
533
+ candidateCapability,
534
+ profile,
347
535
  );
348
536
  const budgetPlan = budget.plan(input.canonicalInput);
349
537
  await store.writeBudgetPlan(budgetPlan);
@@ -363,13 +551,20 @@ export class FusionOrchestrator {
363
551
  usage,
364
552
  budget,
365
553
  calibrationWarnings,
554
+ profile,
555
+ candidateCapability,
366
556
  );
367
557
  await store.transition('candidates_complete');
368
558
  input.onProgress?.({ type: 'state', state: 'candidates_complete' });
369
559
 
370
560
  const shuffled = anonymousCandidates(candidateResults, shuffledSlots(this.randomBytes));
561
+ const validationData = profile.id === 'validate' ? validationSourceData(shuffled.candidates) : undefined;
371
562
  await store.setAnonymousMap(shuffled.map);
372
- const blindInput = buildBlindEvaluationInput(input.canonicalInput, shuffled.candidates);
563
+ const blindInput = buildBlindEvaluationInput(
564
+ input.canonicalInput,
565
+ shuffled.candidates,
566
+ validationData?.findings,
567
+ );
373
568
  await store.writeBlindCandidates(buildEvaluationPrompt(blindInput));
374
569
 
375
570
  await store.transition('evaluating');
@@ -381,6 +576,8 @@ export class FusionOrchestrator {
381
576
  blindInput,
382
577
  budget,
383
578
  calibrationWarnings,
579
+ profile,
580
+ validationData?.findings,
384
581
  );
385
582
  await store.writeEvaluationJson(evaluation);
386
583
  await store.transition('evaluation_complete');
@@ -390,7 +587,7 @@ export class FusionOrchestrator {
390
587
  input.onProgress?.({ type: 'state', state: 'merging' });
391
588
  const mergeInput = buildMergeInput(input.canonicalInput, shuffled.candidates, evaluation);
392
589
  const mergePrompt = buildMergePrompt(mergeInput);
393
- budget.assertStagePrompt('merge', FUSION_MERGER_SYSTEM_PROMPT, mergePrompt);
590
+ budget.assertStagePrompt('merge', profile.mergerSystemPrompt, mergePrompt);
394
591
  input.onProgress?.({ type: 'merge_started' });
395
592
  const merged = await this.runChildWithRetry(
396
593
  input,
@@ -398,9 +595,11 @@ export class FusionOrchestrator {
398
595
  usage,
399
596
  input.models.merger,
400
597
  'merge',
401
- FUSION_MERGER_SYSTEM_PROMPT,
598
+ profile.mergerSystemPrompt,
402
599
  mergePrompt,
403
600
  input.signal ?? new AbortController().signal,
601
+ // Stage policy, not caller input: evaluator and merger are always reasoning-only.
602
+ FUSION_NO_TOOLS_CAPABILITY,
404
603
  undefined,
405
604
  'md',
406
605
  );
@@ -412,23 +611,38 @@ export class FusionOrchestrator {
412
611
  budget,
413
612
  calibrationWarnings,
414
613
  'merge',
415
- FUSION_MERGER_SYSTEM_PROMPT,
614
+ profile.mergerSystemPrompt,
416
615
  mergePrompt,
417
616
  merged,
418
617
  );
419
618
  assertChildOutputWithinContract('merge', merged.text);
420
- await store.writeMerged(merged.text);
619
+ let finalMergedText = merged.text;
620
+ if (profile.id === 'validate') {
621
+ const accounting = evaluation.validation_accounting;
622
+ if (accounting === undefined) {
623
+ throw new FusionError('fusion_validate evaluation completed without validation accounting', {
624
+ code: 'evaluation_invalid',
625
+ stage: 'merge',
626
+ });
627
+ }
628
+ finalMergedText = renderValidatedFusionValidationReport(accounting, validationData);
629
+ }
630
+ if (finalMergedText !== merged.text) assertChildOutputWithinContract('merge', finalMergedText);
631
+ await store.writeMerged(finalMergedText);
421
632
  await store.setUsage(usage);
422
633
  await store.transition('completed');
423
634
  input.onProgress?.({ type: 'completed', runId: store.runId, artifactDir: store.artifactDir });
424
635
  return {
425
- mergedText: merged.text,
636
+ mergedText: finalMergedText,
426
637
  details: {
427
638
  schema_version: FUSION_RESULT_SCHEMA_VERSION,
428
639
  run_id: store.runId,
640
+ workflow: profile.id,
429
641
  source: input.source,
430
642
  status: 'completed',
431
643
  artifact_dir: store.artifactDir,
644
+ context: { kind: inputContextKind, policy_id: input.canonicalInput.context?.policy_id ?? 'fusion-session-projection-v1' },
645
+ tool_policy: { candidate_tools: profile.candidateTools, evaluation_tools: [], merge_tools: [] },
432
646
  models: store.snapshot().models,
433
647
  evaluator_attempts: store
434
648
  .snapshot()
@@ -480,14 +694,17 @@ export class FusionOrchestrator {
480
694
  usage: FusionUsage,
481
695
  budget: FusionBudget,
482
696
  calibrationWarnings: FusionCalibrationViolation[],
697
+ profile: FusionWorkflowProfile,
698
+ candidateCapability: FusionCapability,
483
699
  ): Promise<readonly CandidateResult[]> {
484
700
  const controller = new AbortController();
485
701
  const abortListener = () => controller.abort();
486
702
  input.signal?.addEventListener('abort', abortListener, { once: true });
487
703
  if (input.signal?.aborted) controller.abort();
704
+ const systemPrompt = profile.candidateSystemPrompt(candidateCapability);
488
705
  const prompt = buildCandidatePrompt(input.canonicalInput);
489
706
  for (const slot of [1, 2, 3] as const) {
490
- budget.assertStagePrompt('candidate', FUSION_CANDIDATE_SYSTEM_PROMPT, prompt, slot);
707
+ budget.assertStagePrompt('candidate', systemPrompt, prompt, slot);
491
708
  }
492
709
  let primaryError: unknown;
493
710
  let completed = 0;
@@ -507,9 +724,10 @@ export class FusionOrchestrator {
507
724
  usage,
508
725
  model,
509
726
  'candidate',
510
- FUSION_CANDIDATE_SYSTEM_PROMPT,
727
+ systemPrompt,
511
728
  prompt,
512
729
  controller.signal,
730
+ candidateCapability,
513
731
  slot,
514
732
  'md',
515
733
  ).then(async (result) => {
@@ -520,7 +738,7 @@ export class FusionOrchestrator {
520
738
  budget,
521
739
  calibrationWarnings,
522
740
  'candidate',
523
- FUSION_CANDIDATE_SYSTEM_PROMPT,
741
+ systemPrompt,
524
742
  prompt,
525
743
  result,
526
744
  slot,
@@ -562,9 +780,11 @@ export class FusionOrchestrator {
562
780
  blindInput: Parameters<typeof buildEvaluationPrompt>[0],
563
781
  budget: FusionBudget,
564
782
  calibrationWarnings: FusionCalibrationViolation[],
783
+ profile: FusionWorkflowProfile,
784
+ expectedValidationFindings: readonly FusionValidationFindingRecord[] | undefined,
565
785
  ): Promise<FusionEvaluationV1> {
566
786
  const firstPrompt = buildEvaluationPrompt(blindInput);
567
- budget.assertStagePrompt('evaluation', FUSION_EVALUATOR_SYSTEM_PROMPT, firstPrompt);
787
+ budget.assertStagePrompt('evaluation', profile.evaluatorSystemPrompt, firstPrompt);
568
788
  const first = await this.runEvaluationAttempt(
569
789
  input,
570
790
  store,
@@ -574,6 +794,8 @@ export class FusionOrchestrator {
574
794
  firstPrompt,
575
795
  1,
576
796
  false,
797
+ profile,
798
+ expectedValidationFindings,
577
799
  );
578
800
  if (first.evaluation !== undefined) return first.evaluation;
579
801
  const errors = boundedEvaluationErrors(first.errors);
@@ -586,7 +808,7 @@ export class FusionOrchestrator {
586
808
  });
587
809
  budget.assertStagePrompt(
588
810
  'evaluation_repair',
589
- FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
811
+ profile.evaluationRepairSystemPrompt,
590
812
  repairPrompt,
591
813
  );
592
814
  const second = await this.runEvaluationAttempt(
@@ -598,6 +820,8 @@ export class FusionOrchestrator {
598
820
  repairPrompt,
599
821
  2,
600
822
  true,
823
+ profile,
824
+ expectedValidationFindings,
601
825
  );
602
826
  if (second.evaluation !== undefined) return second.evaluation;
603
827
  throw new FusionError(
@@ -619,11 +843,13 @@ export class FusionOrchestrator {
619
843
  prompt: string,
620
844
  attempt: 1 | 2,
621
845
  repair: boolean,
846
+ profile: FusionWorkflowProfile,
847
+ expectedValidationFindings: readonly FusionValidationFindingRecord[] | undefined,
622
848
  ): Promise<EvaluationAttemptResult> {
623
849
  input.onProgress?.({ type: 'evaluation_started', attempt, repair });
624
850
  const systemPrompt = repair
625
- ? FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT
626
- : FUSION_EVALUATOR_SYSTEM_PROMPT;
851
+ ? profile.evaluationRepairSystemPrompt
852
+ : profile.evaluatorSystemPrompt;
627
853
  const result = await this.runChildWithRetry(
628
854
  input,
629
855
  store,
@@ -633,6 +859,8 @@ export class FusionOrchestrator {
633
859
  systemPrompt,
634
860
  prompt,
635
861
  input.signal ?? new AbortController().signal,
862
+ // Stage policy, not caller input: evaluator and merger are always reasoning-only.
863
+ FUSION_NO_TOOLS_CAPABILITY,
636
864
  undefined,
637
865
  'txt',
638
866
  attempt,
@@ -652,7 +880,7 @@ export class FusionOrchestrator {
652
880
  await store.setUsage(usage);
653
881
  // Bound the evaluator output before it can be embedded in a repair prompt.
654
882
  assertChildOutputWithinContract('evaluation', result.text);
655
- const parsed = parseEvaluationAttempt(result.text);
883
+ const parsed = parseEvaluationAttempt(result.text, expectedValidationFindings);
656
884
  return { result, evaluation: parsed.evaluation, errors: parsed.errors };
657
885
  }
658
886
 
@@ -700,6 +928,7 @@ export class FusionOrchestrator {
700
928
  systemPrompt: string,
701
929
  userPrompt: string,
702
930
  signal: AbortSignal,
931
+ capability: FusionCapability,
703
932
  slot: CandidateSlot | undefined,
704
933
  responseKind: 'md' | 'txt',
705
934
  fixedAttempt?: 1 | 2,
@@ -709,9 +938,29 @@ export class FusionOrchestrator {
709
938
  if (stage === 'candidate' && slot !== undefined) {
710
939
  input.onProgress?.({ type: 'candidate_started', slot, attempt: logicalAttempt });
711
940
  }
941
+ const toolCallLogPath =
942
+ capability !== 'reason'
943
+ ? store.childToolCallLogPath(stage, slot, logicalAttempt)
944
+ : undefined;
945
+ const sourcePolicy =
946
+ capability === 'research'
947
+ ? store.sourcePolicyLaunchReference()
948
+ : undefined;
712
949
  try {
713
950
  return await this.childRunner(
714
- childOptions(input, model, stage, logicalAttempt, systemPrompt, userPrompt, signal, slot),
951
+ childOptions(
952
+ input,
953
+ model,
954
+ stage,
955
+ logicalAttempt,
956
+ capability,
957
+ systemPrompt,
958
+ userPrompt,
959
+ signal,
960
+ slot,
961
+ toolCallLogPath,
962
+ sourcePolicy,
963
+ ),
715
964
  );
716
965
  } catch (error) {
717
966
  if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;