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
@@ -15,6 +15,7 @@ import {
15
15
  type FusionBudgetPlanV1,
16
16
  type FusionCalibrationViolation,
17
17
  type FusionCandidateId,
18
+ type FusionCapability,
18
19
  type FusionContextOmissionLedgerV2,
19
20
  type FusionChildRunResult,
20
21
  type FusionModelConfigV1,
@@ -23,14 +24,21 @@ import {
23
24
  type FusionState,
24
25
  type FusionTerminalState,
25
26
  type FusionUsage,
27
+ type FusionWorkflowId,
26
28
  type ResolvedFusionModels,
27
29
  } from './types.js';
30
+ import { fusionWorkflowProfile, type FusionWorkflowProfile } from './workflows.js';
28
31
 
29
- const RUN_ID_PATTERN = /^f[0-9a-f]{32}$/;
32
+ /**
33
+ * Run ids are prefixed by workflow so an artifact directory is self-describing.
34
+ * The prefix set is closed: an unknown prefix must fail rather than be accepted.
35
+ */
36
+ const RUN_ID_PATTERN = /^(reason|investigate|research|validate)-[0-9a-f]{32}$/;
30
37
 
31
38
  interface MutableFusionArtifactManifest {
32
39
  schema_version: typeof FUSION_MANIFEST_SCHEMA_VERSION;
33
40
  run_id: string;
41
+ workflow: FusionWorkflowId;
34
42
  source: FusionSource;
35
43
  state: FusionState;
36
44
  created_at: string;
@@ -43,6 +51,13 @@ interface MutableFusionArtifactManifest {
43
51
  merger: string;
44
52
  thinking_level: string;
45
53
  };
54
+ capabilities: {
55
+ candidate: FusionCapability;
56
+ evaluation: FusionCapability;
57
+ merge: FusionCapability;
58
+ };
59
+ context: { kind: import('./types.js').FusionContextKind; policy_id: string; ledger_artifact?: string; source_policy_artifact?: string };
60
+ tool_policy: { candidate_tools: readonly string[]; evaluation_tools: readonly []; merge_tools: readonly [] };
46
61
  usage: FusionUsage;
47
62
  attempts: FusionAttemptArtifactRecord[];
48
63
  artifacts: Record<string, FusionArtifactRef>;
@@ -54,9 +69,15 @@ export interface CreateFusionArtifactStoreOptions {
54
69
  cwd: string;
55
70
  sessionId?: string | undefined;
56
71
  runId?: string | undefined;
72
+ profile?: FusionWorkflowProfile | undefined;
57
73
  source: FusionSource;
58
74
  config: FusionModelConfigV1;
59
75
  models: ResolvedFusionModels;
76
+ capabilities?: {
77
+ candidate: FusionCapability;
78
+ evaluation: FusionCapability;
79
+ merge: FusionCapability;
80
+ };
60
81
  now?: () => Date;
61
82
  }
62
83
 
@@ -83,8 +104,8 @@ export interface RecordFusionFailedAttemptInput {
83
104
  usage?: FusionUsage;
84
105
  }
85
106
 
86
- function makeRunId(): string {
87
- return `f${randomBytes(16).toString('hex')}`;
107
+ function makeRunId(profile: FusionWorkflowProfile): string {
108
+ return `${profile.runIdPrefix}${randomBytes(16).toString('hex')}`;
88
109
  }
89
110
 
90
111
  function modelsForManifest(models: ResolvedFusionModels): MutableFusionArtifactManifest['models'] {
@@ -149,6 +170,7 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
149
170
  const out: FusionArtifactManifest = {
150
171
  schema_version: manifest.schema_version,
151
172
  run_id: manifest.run_id,
173
+ workflow: manifest.workflow,
152
174
  source: manifest.source,
153
175
  state: manifest.state,
154
176
  created_at: manifest.created_at,
@@ -156,6 +178,9 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
156
178
  cwd: manifest.cwd,
157
179
  config: manifest.config,
158
180
  models: manifest.models,
181
+ capabilities: manifest.capabilities,
182
+ context: { ...manifest.context },
183
+ tool_policy: { candidate_tools: [...manifest.tool_policy.candidate_tools], evaluation_tools: [], merge_tools: [] },
159
184
  usage: cloneFusionUsage(manifest.usage),
160
185
  attempts: [...manifest.attempts],
161
186
  artifacts: { ...manifest.artifacts },
@@ -183,6 +208,14 @@ function calibrationViolationName(prefix: string): string {
183
208
  return `${prefix}.calibration-violation.json`;
184
209
  }
185
210
 
211
+ function artifactRefSha256Hex(value: string): string {
212
+ const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value;
213
+ if (!/^[0-9a-f]{64}$/u.test(hex)) {
214
+ throw errorForArtifact(`fusion artifact sha256 is not a lowercase hex digest: ${value}`);
215
+ }
216
+ return hex;
217
+ }
218
+
186
219
  export class FusionArtifactStore {
187
220
  private readonly runDirAbs: string;
188
221
  private readonly runDirDisplay: string;
@@ -203,8 +236,14 @@ export class FusionArtifactStore {
203
236
  }
204
237
 
205
238
  static async create(options: CreateFusionArtifactStoreOptions): Promise<FusionArtifactStore> {
206
- const runId = options.runId ?? makeRunId();
239
+ const profile = fusionWorkflowProfile(options.profile?.id ?? 'reason');
240
+ const runId = options.runId ?? makeRunId(profile);
207
241
  if (!RUN_ID_PATTERN.test(runId)) throw errorForArtifact(`invalid fusion run id: ${runId}`);
242
+ if (!runId.startsWith(profile.runIdPrefix)) {
243
+ throw errorForArtifact(
244
+ `fusion run id ${runId} does not carry the ${profile.id} workflow prefix ${profile.runIdPrefix}`,
245
+ );
246
+ }
208
247
  const sessionSegment = sanitizePathSegment(
209
248
  options.sessionId ?? `session-${String(process.pid)}`,
210
249
  );
@@ -217,6 +256,7 @@ export class FusionArtifactStore {
217
256
  const manifest: MutableFusionArtifactManifest = {
218
257
  schema_version: FUSION_MANIFEST_SCHEMA_VERSION,
219
258
  run_id: runId,
259
+ workflow: profile.id,
220
260
  source: options.source,
221
261
  state: 'initializing',
222
262
  created_at: timestamp,
@@ -224,6 +264,13 @@ export class FusionArtifactStore {
224
264
  cwd: options.cwd,
225
265
  config: options.config,
226
266
  models: modelsForManifest(options.models),
267
+ capabilities: options.capabilities ?? {
268
+ candidate: 'reason',
269
+ evaluation: 'reason',
270
+ merge: 'reason',
271
+ },
272
+ context: { kind: profile.contextKind, policy_id: profile.contextKind === 'session_projection' ? 'fusion-session-projection-v1' : 'fusion-clean-task-v1' },
273
+ tool_policy: { candidate_tools: profile.candidateTools, evaluation_tools: [], merge_tools: [] },
227
274
  usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
228
275
  attempts: [],
229
276
  artifacts: {},
@@ -250,6 +297,10 @@ export class FusionArtifactStore {
250
297
  return this.runDirAbs;
251
298
  }
252
299
 
300
+ childToolCallLogPath(stage: FusionStage, slot: 1 | 2 | 3 | undefined, attempt: number): string {
301
+ return this.artifactPath(`${attemptPrefix(stage, slot, attempt)}.tool-calls.jsonl`);
302
+ }
303
+
253
304
  snapshot(): FusionArtifactManifest {
254
305
  return publicManifest(this.manifest);
255
306
  }
@@ -294,7 +345,23 @@ export class FusionArtifactStore {
294
345
  * while the full omission accounting stays locally auditable.
295
346
  */
296
347
  async writeContextLedger(ledger: FusionContextOmissionLedgerV2): Promise<void> {
297
- await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
348
+ const ref = await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
349
+ await this.updateManifest((manifest) => {
350
+ manifest.context.ledger_artifact = ref.path;
351
+ });
352
+ }
353
+
354
+ async writeSourcePolicy(serialized: string): Promise<void> {
355
+ const ref = await this.writeArtifact('source-policy.private.json', serialized);
356
+ await this.updateManifest((manifest) => {
357
+ manifest.context.source_policy_artifact = ref.path;
358
+ });
359
+ }
360
+
361
+ sourcePolicyLaunchReference(): { path: string; sha256: string } {
362
+ const ref = this.manifest.artifacts['source-policy.private.json'];
363
+ if (ref === undefined) throw errorForArtifact('research source policy has not been written');
364
+ return { path: this.artifactPath(ref.path), sha256: artifactRefSha256Hex(ref.sha256) };
298
365
  }
299
366
 
300
367
  /** Route capacities and the pre-candidate whole-workflow feasibility decision. */
@@ -338,6 +405,10 @@ export class FusionArtifactStore {
338
405
  responseName(prefix, input.responseKind),
339
406
  input.result.text,
340
407
  );
408
+ const toolCallsRef =
409
+ input.result.toolCallTrace === undefined
410
+ ? undefined
411
+ : await this.writeArtifact(`${prefix}.tool-calls.jsonl`, input.result.toolCallTrace.bytes);
341
412
  await this.updateManifest((manifest) => {
342
413
  const record: FusionAttemptArtifactRecord = {
343
414
  stage: input.result.stage,
@@ -352,6 +423,10 @@ export class FusionArtifactStore {
352
423
  qualifiedId: input.result.qualifiedId,
353
424
  usage: cloneFusionUsage(input.result.usage),
354
425
  };
426
+ if (toolCallsRef !== undefined && input.result.toolCallTrace !== undefined) {
427
+ record.tool_calls_path = toolCallsRef.path;
428
+ record.tool_calls = { ...input.result.toolCallTrace.summary };
429
+ }
355
430
  if (input.result.slot !== undefined) record.slot = input.result.slot;
356
431
  manifest.attempts.push(record);
357
432
  });
@@ -14,10 +14,6 @@ import {
14
14
  isUsableContextWindow,
15
15
  } from '../context/token-budget.js';
16
16
  import {
17
- FUSION_CANDIDATE_SYSTEM_PROMPT,
18
- FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
19
- FUSION_EVALUATOR_SYSTEM_PROMPT,
20
- FUSION_MERGER_SYSTEM_PROMPT,
21
17
  buildBlindEvaluationInput,
22
18
  buildCandidatePrompt,
23
19
  buildEvaluationPrompt,
@@ -26,6 +22,7 @@ import {
26
22
  buildMergePrompt,
27
23
  type AnonymousFusionCandidate,
28
24
  } from './prompts.js';
25
+ import { FUSION_REASON_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
29
26
  import {
30
27
  FUSION_BUDGET_PLAN_SCHEMA_VERSION,
31
28
  FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
@@ -45,6 +42,7 @@ import {
45
42
  type FusionBudgetWarning,
46
43
  type FusionCalibrationViolation,
47
44
  type FusionCanonicalInputV3,
45
+ type FusionCapability,
48
46
  type FusionEvaluationV1,
49
47
  type FusionRouteCapacity,
50
48
  type FusionStage,
@@ -103,18 +101,61 @@ export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
103
101
  utilization_warning_threshold_basis_points: FUSION_UTILIZATION_WARNING_THRESHOLD_BASIS_POINTS,
104
102
  };
105
103
 
106
- const EMPTY_REMEDIATION: readonly string[] = Object.freeze([
107
- 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
108
- "Raise the route's context window with a larger-context model via /fusion-models.",
109
- 'Restate only the required prior findings as visible conversation text.',
104
+ const REASON_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
105
+ 'Start a fresh Pi conversation, or run fusion_reason earlier in the session.',
106
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
107
+ 'Restate only the required prior findings in the fusion_reason prompt.',
110
108
  ]);
111
109
 
112
- const REQUEST_REMEDIATION: readonly string[] = Object.freeze([
113
- 'Provide a shorter, self-contained fusion_brainstorm prompt.',
114
- 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
115
- "Raise the route's context window with a larger-context model via /fusion-models.",
110
+ const REASON_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
111
+ 'Provide a shorter fusion_reason prompt.',
112
+ 'Start a fresh Pi conversation, or run fusion_reason earlier in the session.',
113
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
116
114
  ]);
117
115
 
116
+ const INVESTIGATE_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
117
+ 'Split the repository investigation into smaller independently complete path or subsystem scopes.',
118
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
119
+ ]);
120
+ const INVESTIGATE_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
121
+ 'Narrow the fusion_investigate objective, repository scope, or required evidence.',
122
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
123
+ ]);
124
+ const RESEARCH_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
125
+ 'Split the research into smaller independently complete source sets.',
126
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
127
+ ]);
128
+ const RESEARCH_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
129
+ 'Narrow the fusion_research question or split large declared-source sets across independent runs.',
130
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
131
+ ]);
132
+ const VALIDATE_EMPTY_REMEDIATION: readonly string[] = Object.freeze([
133
+ 'Split validation into smaller independently complete change or acceptance-criterion scopes.',
134
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
135
+ ]);
136
+ const VALIDATE_REQUEST_REMEDIATION: readonly string[] = Object.freeze([
137
+ 'Narrow the fusion_validate scope, acceptance criteria, or supplied verification evidence.',
138
+ "Raise the route's context window with a larger-context subscription model via /fusion-models.",
139
+ ]);
140
+
141
+ function cleanRemediation(
142
+ profile: FusionWorkflowProfile,
143
+ requestDeterminesFeasibility: boolean,
144
+ ): readonly string[] {
145
+ if (profile.id === 'investigate') {
146
+ return requestDeterminesFeasibility
147
+ ? INVESTIGATE_REQUEST_REMEDIATION
148
+ : INVESTIGATE_EMPTY_REMEDIATION;
149
+ }
150
+ if (profile.id === 'research') {
151
+ return requestDeterminesFeasibility ? RESEARCH_REQUEST_REMEDIATION : RESEARCH_EMPTY_REMEDIATION;
152
+ }
153
+ if (profile.id === 'validate') {
154
+ return requestDeterminesFeasibility ? VALIDATE_REQUEST_REMEDIATION : VALIDATE_EMPTY_REMEDIATION;
155
+ }
156
+ return requestDeterminesFeasibility ? REASON_REQUEST_REMEDIATION : REASON_EMPTY_REMEDIATION;
157
+ }
158
+
118
159
  const RESERVATION_REMEDIATION: readonly string[] = Object.freeze([
119
160
  'Route the blocking stage to a model with larger byte capacity.',
120
161
  'Keep producer output contracts intact; do not shrink or truncate child answers.',
@@ -456,16 +497,28 @@ function replaceRequestText(input: FusionCanonicalInputV3, text: string): Fusion
456
497
  }
457
498
 
458
499
  function visibleTextBytes(input: FusionCanonicalInputV3): number {
500
+ const projection = input.context?.kind === 'session_projection'
501
+ ? input.context.conversation_projection
502
+ : 'conversation_projection' in input
503
+ ? input.conversation_projection
504
+ : undefined;
505
+ if (projection === undefined) return 0;
459
506
  let total = 0;
460
- for (const entry of input.conversation_projection.entries) {
507
+ for (const entry of projection.entries) {
461
508
  if (entry[0] === 't') total += utf8Bytes(JSON.stringify(entry[4]));
462
509
  }
463
510
  return total;
464
511
  }
465
512
 
466
513
  function omissionReceiptBytes(input: FusionCanonicalInputV3): number {
514
+ const projection = input.context?.kind === 'session_projection'
515
+ ? input.context.conversation_projection
516
+ : 'conversation_projection' in input
517
+ ? input.conversation_projection
518
+ : undefined;
519
+ if (projection === undefined) return 0;
467
520
  let total = 0;
468
- for (const entry of input.conversation_projection.entries) {
521
+ for (const entry of projection.entries) {
469
522
  if (entry[0] === 'o') total += utf8Bytes(JSON.stringify(entry));
470
523
  }
471
524
  return total;
@@ -526,10 +579,11 @@ function dominantRemediation(
526
579
  verdict: FusionBudgetEmptyRequestVerdict,
527
580
  composition: FusionBudgetStageComposition | undefined,
528
581
  dominantByteClass: string,
582
+ profile: FusionWorkflowProfile,
529
583
  ): readonly string[] {
530
584
  if (dominantByteClass === 'dense_ascii') return DENSE_REMEDIATION;
531
585
  if (dominantByteClass === 'multibyte') return MULTIBYTE_REMEDIATION;
532
- if (composition === undefined) return remediationFor(verdict);
586
+ if (composition === undefined) return remediationFor(verdict, profile);
533
587
  const entries = [
534
588
  { name: 'visible', bytes: composition.visible_text_bytes },
535
589
  { name: 'request', bytes: composition.request_bytes },
@@ -538,13 +592,24 @@ function dominantRemediation(
538
592
  const dominant = entries[0];
539
593
  if (dominant?.name === 'reservation') return RESERVATION_REMEDIATION;
540
594
  if (dominant?.name === 'request' && !verdict.still_fails_with_empty_request) {
541
- return REQUEST_REMEDIATION;
595
+ return profile.contextKind === 'clean_task'
596
+ ? cleanRemediation(profile, true)
597
+ : REASON_REQUEST_REMEDIATION;
542
598
  }
543
- return EMPTY_REMEDIATION;
599
+ if (profile.contextKind === 'clean_task') return cleanRemediation(profile, false);
600
+ return REASON_EMPTY_REMEDIATION;
544
601
  }
545
602
 
546
- function remediationFor(verdict: FusionBudgetEmptyRequestVerdict): readonly string[] {
547
- return verdict.still_fails_with_empty_request ? EMPTY_REMEDIATION : REQUEST_REMEDIATION;
603
+ function remediationFor(
604
+ verdict: FusionBudgetEmptyRequestVerdict,
605
+ profile: FusionWorkflowProfile,
606
+ ): readonly string[] {
607
+ if (profile.contextKind === 'clean_task') {
608
+ return cleanRemediation(profile, !verdict.still_fails_with_empty_request);
609
+ }
610
+ return verdict.still_fails_with_empty_request
611
+ ? REASON_EMPTY_REMEDIATION
612
+ : REASON_REQUEST_REMEDIATION;
548
613
  }
549
614
 
550
615
  function formatEmptyRequestVerdict(verdict: FusionBudgetEmptyRequestVerdict): string {
@@ -677,11 +742,20 @@ export class FusionBudget {
677
742
  readonly routes: readonly FusionRouteCapacity[];
678
743
  readonly limiting: FusionRouteCapacity;
679
744
  private readonly contextPolicyId: string;
680
-
681
- constructor(models: ResolvedFusionModels, contextPolicyId: string) {
745
+ private readonly candidateCapability: FusionCapability;
746
+ private readonly profile: FusionWorkflowProfile;
747
+
748
+ constructor(
749
+ models: ResolvedFusionModels,
750
+ contextPolicyId: string,
751
+ candidateCapability: FusionCapability = FUSION_REASON_WORKFLOW.candidateCapability,
752
+ profile: FusionWorkflowProfile = FUSION_REASON_WORKFLOW,
753
+ ) {
682
754
  this.routes = fusionRouteCapacities(models);
683
755
  this.limiting = fusionLimitingRoute(this.routes);
684
756
  this.contextPolicyId = contextPolicyId;
757
+ this.candidateCapability = candidateCapability;
758
+ this.profile = profile;
685
759
  }
686
760
 
687
761
  get allowedInputTokens(): number {
@@ -708,6 +782,7 @@ export class FusionBudget {
708
782
  }
709
783
 
710
784
  private drafts(input: FusionCanonicalInputV3): readonly StageForecastDraft[] {
785
+ const candidateSystemPrompt = this.profile.candidateSystemPrompt(this.candidateCapability);
711
786
  const candidatePrompt = buildCandidatePrompt(input);
712
787
  const blindInput = buildBlindEvaluationInput(input, EMPTY_CANDIDATES);
713
788
  const evaluationPrompt = buildEvaluationPrompt(blindInput);
@@ -724,7 +799,7 @@ export class FusionBudget {
724
799
  slot: 1,
725
800
  route: this.routeForStage('candidate', 1),
726
801
  conditional: false,
727
- system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
802
+ system_prompt: candidateSystemPrompt,
728
803
  empty_user_prompt: candidatePrompt,
729
804
  upstream_output_contract_bytes: 0,
730
805
  },
@@ -733,7 +808,7 @@ export class FusionBudget {
733
808
  slot: 2,
734
809
  route: this.routeForStage('candidate', 2),
735
810
  conditional: false,
736
- system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
811
+ system_prompt: candidateSystemPrompt,
737
812
  empty_user_prompt: candidatePrompt,
738
813
  upstream_output_contract_bytes: 0,
739
814
  },
@@ -742,7 +817,7 @@ export class FusionBudget {
742
817
  slot: 3,
743
818
  route: this.routeForStage('candidate', 3),
744
819
  conditional: false,
745
- system_prompt: FUSION_CANDIDATE_SYSTEM_PROMPT,
820
+ system_prompt: candidateSystemPrompt,
746
821
  empty_user_prompt: candidatePrompt,
747
822
  upstream_output_contract_bytes: 0,
748
823
  },
@@ -750,7 +825,7 @@ export class FusionBudget {
750
825
  budget_stage: 'evaluation',
751
826
  route: this.routeForStage('evaluation'),
752
827
  conditional: false,
753
- system_prompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
828
+ system_prompt: this.profile.evaluatorSystemPrompt,
754
829
  empty_user_prompt: evaluationPrompt,
755
830
  upstream_output_contract_bytes: 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
756
831
  },
@@ -758,7 +833,7 @@ export class FusionBudget {
758
833
  budget_stage: 'merge',
759
834
  route: this.routeForStage('merge'),
760
835
  conditional: false,
761
- system_prompt: FUSION_MERGER_SYSTEM_PROMPT,
836
+ system_prompt: this.profile.mergerSystemPrompt,
762
837
  empty_user_prompt: mergePrompt,
763
838
  upstream_output_contract_bytes:
764
839
  3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES + FUSION_EVALUATION_MAX_OUTPUT_BYTES,
@@ -767,7 +842,7 @@ export class FusionBudget {
767
842
  budget_stage: 'evaluation_repair',
768
843
  route: this.routeForStage('evaluation_repair'),
769
844
  conditional: true,
770
- system_prompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
845
+ system_prompt: this.profile.evaluationRepairSystemPrompt,
771
846
  empty_user_prompt: repairPrompt,
772
847
  upstream_output_contract_bytes:
773
848
  3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES +
@@ -842,7 +917,12 @@ export class FusionBudget {
842
917
  ): FusionError {
843
918
  const composition = plan.primary_blocker_composition;
844
919
  const dominantByteClass = primary.input_only_estimate.rateSource.dominant_byte_class;
845
- const remediation = dominantRemediation(plan.empty_request, composition, dominantByteClass);
920
+ const remediation = dominantRemediation(
921
+ plan.empty_request,
922
+ composition,
923
+ dominantByteClass,
924
+ this.profile,
925
+ );
846
926
  const tokensOver = primary.input_only_input_tokens_upper_bound - primary.allowed_input_tokens;
847
927
  const budget: FusionBudgetErrorDetail = {
848
928
  budget_stage: primary.budget_stage,
@@ -916,6 +996,25 @@ export class FusionBudget {
916
996
  return new FusionError(message, details);
917
997
  }
918
998
 
999
+ private planMetadata(input?: FusionCanonicalInputV3): Pick<FusionBudgetPlanV1, 'workflow' | 'context' | 'fixed_candidate_policy' | 'tool_policy'> {
1000
+ return {
1001
+ workflow: this.profile.id,
1002
+ context: {
1003
+ kind: this.profile.contextKind,
1004
+ policy_id: input?.context?.policy_id ?? this.contextPolicyId,
1005
+ },
1006
+ fixed_candidate_policy: {
1007
+ capability: this.candidateCapability,
1008
+ tools: this.profile.candidateTools,
1009
+ },
1010
+ tool_policy: {
1011
+ candidate_tools: this.profile.candidateTools,
1012
+ evaluation_tools: [] as readonly [],
1013
+ merge_tools: [] as readonly [],
1014
+ },
1015
+ };
1016
+ }
1017
+
919
1018
  plan(input: FusionCanonicalInputV3): FusionBudgetPlanV1 {
920
1019
  const stages = this.entries(input);
921
1020
  const blockers = selectBlockers(stages);
@@ -923,6 +1022,7 @@ export class FusionBudget {
923
1022
  const emptyRequest = this.emptyRequestVerdict(input, stages);
924
1023
  const base: FusionBudgetPlanV1 = {
925
1024
  schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
1025
+ ...this.planMetadata(input),
926
1026
  policy: FUSION_BUDGET_POLICY,
927
1027
  routes: this.routes,
928
1028
  stages,
@@ -985,6 +1085,7 @@ export class FusionBudget {
985
1085
  const blocker = blockerFromEntry(entry);
986
1086
  const plan: FusionBudgetPlanV1 = {
987
1087
  schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
1088
+ ...this.planMetadata(),
988
1089
  policy: FUSION_BUDGET_POLICY,
989
1090
  routes: this.routes,
990
1091
  stages: [entry],
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Usage } from '@earendil-works/pi-ai';
3
+
4
+ export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
5
+ 'pi-background-tasks.fusion-child-result.v2' as const;
6
+ export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
7
+ export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
8
+ export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
9
+ export const FUSION_SOURCE_POLICY_PATH_ENV = 'PI_FUSION_SOURCE_POLICY_PATH';
10
+ export const FUSION_SOURCE_POLICY_SHA256_ENV = 'PI_FUSION_SOURCE_POLICY_SHA256';
11
+ export const FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION =
12
+ 'pi-background-tasks.fusion-tool-call-seal.v1' as const;
13
+ export const FUSION_TOOL_CALL_SEAL_SUFFIX = '.seal.json';
14
+
15
+ /**
16
+ * Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
17
+ *
18
+ * v1 deliberately has no tool-call-count cap, so this byte budget is the only bound on
19
+ * how much a read-only candidate can pull into its context. 8 MiB is generous for
20
+ * targeted grep/read investigation while still preventing an unbounded read loop from
21
+ * degrading into an opaque provider-side context failure.
22
+ */
23
+ export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
24
+
25
+ export interface FusionChildTextBlockMetadata {
26
+ utf8_bytes: number;
27
+ sha256: string;
28
+ }
29
+
30
+ export type FusionChildResultUsageMetadata = Usage;
31
+
32
+ export interface FusionChildResultMetadata {
33
+ schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
34
+ provider: string;
35
+ model: string;
36
+ stop_reason: string;
37
+ text_blocks: FusionChildTextBlockMetadata[];
38
+ text_sha256: string;
39
+ usage: FusionChildResultUsageMetadata;
40
+ }
41
+
42
+ function protocolSha256(value: string | Buffer): string {
43
+ return createHash('sha256').update(value).digest('hex');
44
+ }
45
+
46
+ export function buildFusionChildResultMetadata(message: {
47
+ provider: string;
48
+ model: string;
49
+ stopReason: string;
50
+ content: ReadonlyArray<{ type: string; text?: string }>;
51
+ usage: Usage;
52
+ }): FusionChildResultMetadata {
53
+ const textBlocks = message.content.flatMap((part) =>
54
+ part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
55
+ );
56
+ const usage: FusionChildResultUsageMetadata = {
57
+ input: message.usage.input,
58
+ output: message.usage.output,
59
+ cacheRead: message.usage.cacheRead,
60
+ cacheWrite: message.usage.cacheWrite,
61
+ totalTokens: message.usage.totalTokens,
62
+ cost: {
63
+ input: message.usage.cost.input,
64
+ output: message.usage.cost.output,
65
+ cacheRead: message.usage.cost.cacheRead,
66
+ cacheWrite: message.usage.cost.cacheWrite,
67
+ total: message.usage.cost.total,
68
+ },
69
+ };
70
+ return {
71
+ schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
72
+ provider: message.provider,
73
+ model: message.model,
74
+ stop_reason: message.stopReason,
75
+ text_blocks: textBlocks.map((text) => ({
76
+ utf8_bytes: Buffer.byteLength(text, 'utf8'),
77
+ sha256: protocolSha256(text),
78
+ })),
79
+ text_sha256: protocolSha256(textBlocks.join('')),
80
+ usage,
81
+ };
82
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from '../attested-pi-run.js';
3
+ import { normalizeFusionDeclaredSources, type DeclaredFusionSourceInput } from './source-policy.js';
4
+ import {
5
+ FUSION_INPUT_SCHEMA_VERSION,
6
+ FusionError,
7
+ type FusionCanonicalRequestV3,
8
+ type FusionCleanTaskCanonicalInputV5,
9
+ type FusionDeclaredSourceV1,
10
+ type FusionSource,
11
+ type FusionWorkflowId,
12
+ } from './types.js';
13
+
14
+ export interface BuildFusionCleanTaskInputOptions {
15
+ cwd: string;
16
+ source: FusionSource;
17
+ request: string;
18
+ workflow: Exclude<FusionWorkflowId, 'reason'>;
19
+ declaredSources?: readonly DeclaredFusionSourceInput[] | undefined;
20
+ }
21
+
22
+ /**
23
+ * Public v1 clean builder. It is deliberately pure: callers provide cwd and
24
+ * normalized request text explicitly, and this module has no dependency on Pi
25
+ * session, snapshot, parent-context, or visible-conversation APIs.
26
+ */
27
+ export const buildCleanFusionCanonicalInput = buildFusionCleanTaskCanonicalInput;
28
+
29
+ export interface BuiltFusionCleanTaskCanonicalInput {
30
+ input: FusionCleanTaskCanonicalInputV5;
31
+ serialized: string;
32
+ declaredSources: readonly FusionDeclaredSourceV1[];
33
+ transcriptLeafId: null;
34
+ }
35
+
36
+ function sha256Text(value: string): string {
37
+ return createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex');
38
+ }
39
+
40
+ export function buildFusionCleanTaskCanonicalInput(
41
+ options: BuildFusionCleanTaskInputOptions,
42
+ ): BuiltFusionCleanTaskCanonicalInput {
43
+ if (options.request.trim().length === 0) {
44
+ throw new FusionError('fusion request must not be blank', {
45
+ code: 'context_capture_failed',
46
+ childCreated: false,
47
+ });
48
+ }
49
+ if (!['investigate', 'research', 'validate'].includes(options.workflow)) {
50
+ throw new FusionError('clean-task fusion input is available only to investigate, research, and validate workflows', {
51
+ code: 'context_capture_failed',
52
+ childCreated: false,
53
+ });
54
+ }
55
+ const declaredSources = normalizeFusionDeclaredSources(options.declaredSources ?? []);
56
+ if (options.workflow === 'research' && declaredSources.length === 0) {
57
+ throw new FusionError('fusion research requires at least one declared source URL and purpose', {
58
+ code: 'context_capture_failed',
59
+ childCreated: false,
60
+ });
61
+ }
62
+ if (options.workflow !== 'research' && declaredSources.length > 0) {
63
+ throw new FusionError('declared sources are accepted only by the research workflow', {
64
+ code: 'context_capture_failed',
65
+ childCreated: false,
66
+ });
67
+ }
68
+ const request: FusionCanonicalRequestV3 = {
69
+ source: options.source,
70
+ authority: 'explicit_text',
71
+ text: options.request,
72
+ sha256: sha256Text(options.request),
73
+ };
74
+ const input: FusionCleanTaskCanonicalInputV5 = {
75
+ schema_version: FUSION_INPUT_SCHEMA_VERSION,
76
+ workflow: options.workflow,
77
+ cwd: options.cwd,
78
+ request,
79
+ context: {
80
+ kind: 'clean_task',
81
+ policy_id: 'fusion-clean-task-v1',
82
+ declared_sources: declaredSources,
83
+ },
84
+ };
85
+ return {
86
+ input,
87
+ serialized: canonicalJson(input),
88
+ declaredSources,
89
+ transcriptLeafId: null,
90
+ };
91
+ }