pi-background-tasks 0.9.0 → 1.0.4
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.
- package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
- package/PUBLISHING.md +43 -29
- package/README.md +233 -441
- package/TESTING.md +16 -10
- package/TEST_PLAN.md +43 -17
- package/docs/INDEX.md +157 -0
- package/docs/api/eventbus-v1.md +166 -0
- package/docs/assets/architecture.svg +78 -0
- package/docs/assets/footer-dock.svg +47 -0
- package/docs/assets/logo.svg +49 -0
- package/docs/attestations.json +189 -0
- package/docs/choose-a-workflow.md +98 -0
- package/docs/commands/bg-clear.md +70 -0
- package/docs/commands/bg-update.md +82 -0
- package/docs/commands/bg.md +90 -0
- package/docs/commands/fusion-models.md +70 -0
- package/docs/commands/fusion.md +69 -0
- package/docs/commands/jobs.md +74 -0
- package/docs/commands/kill.md +82 -0
- package/docs/commands/logs.md +90 -0
- package/docs/commands/task-manager.md +109 -0
- package/docs/concepts/completion-delivery.md +66 -0
- package/docs/concepts/context-projection-and-budgeting.md +79 -0
- package/docs/getting-started.md +122 -0
- package/docs/manifest.json +1825 -0
- package/docs/operations/configuration.md +110 -0
- package/docs/operations/releasing.md +67 -0
- package/docs/operations/testing.md +101 -0
- package/docs/operations/troubleshooting.md +38 -0
- package/docs/read-before-edit.md +94 -0
- package/docs/reference/runtime-contracts.md +213 -0
- package/docs/reference/shortcuts-and-dock.md +70 -0
- package/docs/subsystems/attested-pi-runs.md +141 -0
- package/docs/subsystems/background-task-runtime.md +85 -0
- package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
- package/docs/subsystems/delegation.md +190 -0
- package/docs/subsystems/docs-freshness-gate.md +26 -0
- package/docs/subsystems/fusion.md +123 -0
- package/docs/subsystems/host-ui-and-telemetry.md +83 -0
- package/docs/tools/bg_delegate.md +193 -0
- package/docs/tools/bg_kill.md +114 -0
- package/docs/tools/bg_logs.md +133 -0
- package/docs/tools/bg_result.md +120 -0
- package/docs/tools/bg_run.md +168 -0
- package/docs/tools/bg_run_pi_attested.md +170 -0
- package/docs/tools/bg_status.md +111 -0
- package/docs/tools/fusion_investigate.md +116 -0
- package/docs/tools/fusion_reason.md +75 -0
- package/docs/tools/fusion_research.md +162 -0
- package/docs/tools/fusion_validate.md +206 -0
- package/logo.png +0 -0
- package/package.json +27 -9
- package/src/core/delegate/budget.ts +1 -1
- package/src/core/delegate/launch.ts +5 -0
- package/src/core/fusion/artifacts.ts +34 -4
- package/src/core/fusion/budget.ts +112 -20
- package/src/core/fusion/child-protocol.ts +82 -0
- package/src/core/fusion/clean-context.ts +91 -0
- package/src/core/fusion/config.ts +124 -35
- package/src/core/fusion/context.ts +29 -7
- package/src/core/fusion/evaluation.ts +392 -15
- package/src/core/fusion/orchestrator.ts +217 -23
- package/src/core/fusion/pi-child.ts +227 -24
- package/src/core/fusion/prompts.ts +39 -26
- package/src/core/fusion/source-policy.ts +257 -0
- package/src/core/fusion/types.ts +156 -11
- package/src/core/fusion/web-fetch.ts +104 -15
- package/src/core/fusion/workflows.ts +119 -65
- package/src/extension.ts +3 -3
- package/src/fusion-child-extension.ts +375 -141
- package/src/fusion-extension.ts +585 -240
- package/src/testing/normalize.ts +0 -22
|
@@ -27,13 +27,13 @@ import {
|
|
|
27
27
|
type FusionWorkflowId,
|
|
28
28
|
type ResolvedFusionModels,
|
|
29
29
|
} from './types.js';
|
|
30
|
-
import {
|
|
30
|
+
import { fusionWorkflowProfile, type FusionWorkflowProfile } from './workflows.js';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Run ids are prefixed by workflow so an artifact directory is self-describing.
|
|
34
34
|
* The prefix set is closed: an unknown prefix must fail rather than be accepted.
|
|
35
35
|
*/
|
|
36
|
-
const RUN_ID_PATTERN = /^[
|
|
36
|
+
const RUN_ID_PATTERN = /^(reason|investigate|research|validate)-[0-9a-f]{32}$/;
|
|
37
37
|
|
|
38
38
|
interface MutableFusionArtifactManifest {
|
|
39
39
|
schema_version: typeof FUSION_MANIFEST_SCHEMA_VERSION;
|
|
@@ -56,6 +56,8 @@ interface MutableFusionArtifactManifest {
|
|
|
56
56
|
evaluation: FusionCapability;
|
|
57
57
|
merge: FusionCapability;
|
|
58
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 [] };
|
|
59
61
|
usage: FusionUsage;
|
|
60
62
|
attempts: FusionAttemptArtifactRecord[];
|
|
61
63
|
artifacts: Record<string, FusionArtifactRef>;
|
|
@@ -177,6 +179,8 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
|
|
|
177
179
|
config: manifest.config,
|
|
178
180
|
models: manifest.models,
|
|
179
181
|
capabilities: manifest.capabilities,
|
|
182
|
+
context: { ...manifest.context },
|
|
183
|
+
tool_policy: { candidate_tools: [...manifest.tool_policy.candidate_tools], evaluation_tools: [], merge_tools: [] },
|
|
180
184
|
usage: cloneFusionUsage(manifest.usage),
|
|
181
185
|
attempts: [...manifest.attempts],
|
|
182
186
|
artifacts: { ...manifest.artifacts },
|
|
@@ -204,6 +208,14 @@ function calibrationViolationName(prefix: string): string {
|
|
|
204
208
|
return `${prefix}.calibration-violation.json`;
|
|
205
209
|
}
|
|
206
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
|
+
|
|
207
219
|
export class FusionArtifactStore {
|
|
208
220
|
private readonly runDirAbs: string;
|
|
209
221
|
private readonly runDirDisplay: string;
|
|
@@ -224,7 +236,7 @@ export class FusionArtifactStore {
|
|
|
224
236
|
}
|
|
225
237
|
|
|
226
238
|
static async create(options: CreateFusionArtifactStoreOptions): Promise<FusionArtifactStore> {
|
|
227
|
-
const profile = options.profile ??
|
|
239
|
+
const profile = fusionWorkflowProfile(options.profile?.id ?? 'reason');
|
|
228
240
|
const runId = options.runId ?? makeRunId(profile);
|
|
229
241
|
if (!RUN_ID_PATTERN.test(runId)) throw errorForArtifact(`invalid fusion run id: ${runId}`);
|
|
230
242
|
if (!runId.startsWith(profile.runIdPrefix)) {
|
|
@@ -257,6 +269,8 @@ export class FusionArtifactStore {
|
|
|
257
269
|
evaluation: 'reason',
|
|
258
270
|
merge: 'reason',
|
|
259
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: [] },
|
|
260
274
|
usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
|
|
261
275
|
attempts: [],
|
|
262
276
|
artifacts: {},
|
|
@@ -331,7 +345,23 @@ export class FusionArtifactStore {
|
|
|
331
345
|
* while the full omission accounting stays locally auditable.
|
|
332
346
|
*/
|
|
333
347
|
async writeContextLedger(ledger: FusionContextOmissionLedgerV2): Promise<void> {
|
|
334
|
-
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) };
|
|
335
365
|
}
|
|
336
366
|
|
|
337
367
|
/** Route capacities and the pre-candidate whole-workflow feasibility decision. */
|
|
@@ -22,12 +22,11 @@ import {
|
|
|
22
22
|
buildMergePrompt,
|
|
23
23
|
type AnonymousFusionCandidate,
|
|
24
24
|
} from './prompts.js';
|
|
25
|
-
import {
|
|
25
|
+
import { FUSION_REASON_WORKFLOW, type FusionWorkflowProfile } from './workflows.js';
|
|
26
26
|
import {
|
|
27
27
|
FUSION_BUDGET_PLAN_SCHEMA_VERSION,
|
|
28
28
|
FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION,
|
|
29
29
|
FUSION_EVALUATION_SCHEMA_VERSION,
|
|
30
|
-
FUSION_DEFAULT_CAPABILITY,
|
|
31
30
|
FusionError,
|
|
32
31
|
type FusionBudgetBlocker,
|
|
33
32
|
type FusionBudgetCheckKind,
|
|
@@ -102,18 +101,61 @@ export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
|
|
|
102
101
|
utilization_warning_threshold_basis_points: FUSION_UTILIZATION_WARNING_THRESHOLD_BASIS_POINTS,
|
|
103
102
|
};
|
|
104
103
|
|
|
105
|
-
const
|
|
106
|
-
'Start a fresh Pi conversation, or run
|
|
107
|
-
"Raise the route's context window with a larger-context model via /fusion-models.",
|
|
108
|
-
'Restate only the required prior findings
|
|
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.',
|
|
109
108
|
]);
|
|
110
109
|
|
|
111
|
-
const
|
|
112
|
-
'Provide a shorter
|
|
113
|
-
'Start a fresh Pi conversation, or run
|
|
114
|
-
"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.",
|
|
115
114
|
]);
|
|
116
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
|
+
|
|
117
159
|
const RESERVATION_REMEDIATION: readonly string[] = Object.freeze([
|
|
118
160
|
'Route the blocking stage to a model with larger byte capacity.',
|
|
119
161
|
'Keep producer output contracts intact; do not shrink or truncate child answers.',
|
|
@@ -455,16 +497,28 @@ function replaceRequestText(input: FusionCanonicalInputV3, text: string): Fusion
|
|
|
455
497
|
}
|
|
456
498
|
|
|
457
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;
|
|
458
506
|
let total = 0;
|
|
459
|
-
for (const entry of
|
|
507
|
+
for (const entry of projection.entries) {
|
|
460
508
|
if (entry[0] === 't') total += utf8Bytes(JSON.stringify(entry[4]));
|
|
461
509
|
}
|
|
462
510
|
return total;
|
|
463
511
|
}
|
|
464
512
|
|
|
465
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;
|
|
466
520
|
let total = 0;
|
|
467
|
-
for (const entry of
|
|
521
|
+
for (const entry of projection.entries) {
|
|
468
522
|
if (entry[0] === 'o') total += utf8Bytes(JSON.stringify(entry));
|
|
469
523
|
}
|
|
470
524
|
return total;
|
|
@@ -525,10 +579,11 @@ function dominantRemediation(
|
|
|
525
579
|
verdict: FusionBudgetEmptyRequestVerdict,
|
|
526
580
|
composition: FusionBudgetStageComposition | undefined,
|
|
527
581
|
dominantByteClass: string,
|
|
582
|
+
profile: FusionWorkflowProfile,
|
|
528
583
|
): readonly string[] {
|
|
529
584
|
if (dominantByteClass === 'dense_ascii') return DENSE_REMEDIATION;
|
|
530
585
|
if (dominantByteClass === 'multibyte') return MULTIBYTE_REMEDIATION;
|
|
531
|
-
if (composition === undefined) return remediationFor(verdict);
|
|
586
|
+
if (composition === undefined) return remediationFor(verdict, profile);
|
|
532
587
|
const entries = [
|
|
533
588
|
{ name: 'visible', bytes: composition.visible_text_bytes },
|
|
534
589
|
{ name: 'request', bytes: composition.request_bytes },
|
|
@@ -537,13 +592,24 @@ function dominantRemediation(
|
|
|
537
592
|
const dominant = entries[0];
|
|
538
593
|
if (dominant?.name === 'reservation') return RESERVATION_REMEDIATION;
|
|
539
594
|
if (dominant?.name === 'request' && !verdict.still_fails_with_empty_request) {
|
|
540
|
-
return
|
|
595
|
+
return profile.contextKind === 'clean_task'
|
|
596
|
+
? cleanRemediation(profile, true)
|
|
597
|
+
: REASON_REQUEST_REMEDIATION;
|
|
541
598
|
}
|
|
542
|
-
return
|
|
599
|
+
if (profile.contextKind === 'clean_task') return cleanRemediation(profile, false);
|
|
600
|
+
return REASON_EMPTY_REMEDIATION;
|
|
543
601
|
}
|
|
544
602
|
|
|
545
|
-
function remediationFor(
|
|
546
|
-
|
|
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;
|
|
547
613
|
}
|
|
548
614
|
|
|
549
615
|
function formatEmptyRequestVerdict(verdict: FusionBudgetEmptyRequestVerdict): string {
|
|
@@ -682,8 +748,8 @@ export class FusionBudget {
|
|
|
682
748
|
constructor(
|
|
683
749
|
models: ResolvedFusionModels,
|
|
684
750
|
contextPolicyId: string,
|
|
685
|
-
candidateCapability: FusionCapability =
|
|
686
|
-
profile: FusionWorkflowProfile =
|
|
751
|
+
candidateCapability: FusionCapability = FUSION_REASON_WORKFLOW.candidateCapability,
|
|
752
|
+
profile: FusionWorkflowProfile = FUSION_REASON_WORKFLOW,
|
|
687
753
|
) {
|
|
688
754
|
this.routes = fusionRouteCapacities(models);
|
|
689
755
|
this.limiting = fusionLimitingRoute(this.routes);
|
|
@@ -851,7 +917,12 @@ export class FusionBudget {
|
|
|
851
917
|
): FusionError {
|
|
852
918
|
const composition = plan.primary_blocker_composition;
|
|
853
919
|
const dominantByteClass = primary.input_only_estimate.rateSource.dominant_byte_class;
|
|
854
|
-
const remediation = dominantRemediation(
|
|
920
|
+
const remediation = dominantRemediation(
|
|
921
|
+
plan.empty_request,
|
|
922
|
+
composition,
|
|
923
|
+
dominantByteClass,
|
|
924
|
+
this.profile,
|
|
925
|
+
);
|
|
855
926
|
const tokensOver = primary.input_only_input_tokens_upper_bound - primary.allowed_input_tokens;
|
|
856
927
|
const budget: FusionBudgetErrorDetail = {
|
|
857
928
|
budget_stage: primary.budget_stage,
|
|
@@ -925,6 +996,25 @@ export class FusionBudget {
|
|
|
925
996
|
return new FusionError(message, details);
|
|
926
997
|
}
|
|
927
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
|
+
|
|
928
1018
|
plan(input: FusionCanonicalInputV3): FusionBudgetPlanV1 {
|
|
929
1019
|
const stages = this.entries(input);
|
|
930
1020
|
const blockers = selectBlockers(stages);
|
|
@@ -932,6 +1022,7 @@ export class FusionBudget {
|
|
|
932
1022
|
const emptyRequest = this.emptyRequestVerdict(input, stages);
|
|
933
1023
|
const base: FusionBudgetPlanV1 = {
|
|
934
1024
|
schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
|
|
1025
|
+
...this.planMetadata(input),
|
|
935
1026
|
policy: FUSION_BUDGET_POLICY,
|
|
936
1027
|
routes: this.routes,
|
|
937
1028
|
stages,
|
|
@@ -994,6 +1085,7 @@ export class FusionBudget {
|
|
|
994
1085
|
const blocker = blockerFromEntry(entry);
|
|
995
1086
|
const plan: FusionBudgetPlanV1 = {
|
|
996
1087
|
schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
|
|
1088
|
+
...this.planMetadata(),
|
|
997
1089
|
policy: FUSION_BUDGET_POLICY,
|
|
998
1090
|
routes: this.routes,
|
|
999
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
|
+
}
|
|
@@ -24,6 +24,7 @@ export interface FusionModelRegistry {
|
|
|
24
24
|
getAll(): Model<Api>[];
|
|
25
25
|
getAvailable(): Model<Api>[];
|
|
26
26
|
find?(provider: string, modelId: string): Model<Api> | undefined;
|
|
27
|
+
isUsingOAuth?(model: Model<Api>): boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ResolveFusionModelsInput {
|
|
@@ -163,12 +164,119 @@ function modelIndex(models: readonly Model<Api>[]): Map<string, Model<Api>> {
|
|
|
163
164
|
return out;
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
const FRONTIER_MODEL_PATTERN =
|
|
168
|
+
/(?:^|[-_/])(?:gpt|codex|claude|opus|sonnet|o[134](?:-[a-z0-9.]+)*)(?:[-_/]|$)/iu;
|
|
169
|
+
const TRUSTED_SUBSCRIPTION_ENDPOINTS = Object.freeze({
|
|
170
|
+
anthropic: 'https://api.anthropic.com',
|
|
171
|
+
'openai-codex': 'https://chatgpt.com/backend-api',
|
|
172
|
+
} as const);
|
|
173
|
+
const AUTH_HEADER_NAMES = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'api-key']);
|
|
174
|
+
|
|
175
|
+
function isKnownFrontierEndpoint(baseUrl: string | undefined): boolean {
|
|
176
|
+
if (baseUrl === undefined || baseUrl.trim().length === 0) return false;
|
|
177
|
+
try {
|
|
178
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/u, '');
|
|
179
|
+
return (
|
|
180
|
+
hostname === 'api.openai.com' ||
|
|
181
|
+
hostname === 'api.anthropic.com' ||
|
|
182
|
+
hostname === 'openrouter.ai' ||
|
|
183
|
+
hostname === 'chatgpt.com' ||
|
|
184
|
+
hostname.endsWith('.openai.azure.com') ||
|
|
185
|
+
hostname.endsWith('.cognitiveservices.azure.com') ||
|
|
186
|
+
hostname.endsWith('.ai.azure.com')
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assertTrustedSubscriptionEndpoint(
|
|
194
|
+
model: Model<Api>,
|
|
195
|
+
slotLabel: string,
|
|
196
|
+
provider: keyof typeof TRUSTED_SUBSCRIPTION_ENDPOINTS,
|
|
197
|
+
): void {
|
|
198
|
+
const expectedText = TRUSTED_SUBSCRIPTION_ENDPOINTS[provider];
|
|
199
|
+
const effectiveText = model.baseUrl?.trim() || expectedText;
|
|
200
|
+
let effective: URL;
|
|
201
|
+
try {
|
|
202
|
+
effective = new URL(effectiveText);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new FusionError(
|
|
205
|
+
`${slotLabel} route ${model.provider}/${model.id} has a malformed subscription endpoint`,
|
|
206
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const expected = new URL(expectedText);
|
|
210
|
+
const effectivePath = effective.href.slice(effective.origin.length).replace(/\/+$/u, '');
|
|
211
|
+
const expectedPath = expected.href.slice(expected.origin.length).replace(/\/+$/u, '');
|
|
212
|
+
if (
|
|
213
|
+
effective.protocol !== 'https:' ||
|
|
214
|
+
effective.username !== '' ||
|
|
215
|
+
effective.password !== '' ||
|
|
216
|
+
effective.search !== '' ||
|
|
217
|
+
effective.hash !== '' ||
|
|
218
|
+
effective.origin !== expected.origin ||
|
|
219
|
+
effectivePath !== expectedPath
|
|
220
|
+
) {
|
|
221
|
+
throw new FusionError(
|
|
222
|
+
`${slotLabel} route ${model.provider}/${model.id} does not use the trusted Pi subscription endpoint ${expectedText}`,
|
|
223
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const unsafeHeader = Object.keys(model.headers ?? {}).find((name) =>
|
|
227
|
+
AUTH_HEADER_NAMES.has(name.toLowerCase()),
|
|
228
|
+
);
|
|
229
|
+
if (unsafeHeader !== undefined) {
|
|
230
|
+
throw new FusionError(
|
|
231
|
+
`${slotLabel} route ${model.provider}/${model.id} overrides subscription authentication header ${unsafeHeader}`,
|
|
232
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertSubscriptionRoute(
|
|
238
|
+
model: Model<Api>,
|
|
239
|
+
slotLabel: string,
|
|
240
|
+
registry: FusionModelRegistry,
|
|
241
|
+
): void {
|
|
242
|
+
const provider = model.provider.toLowerCase();
|
|
243
|
+
const frontier =
|
|
244
|
+
provider === 'openai' ||
|
|
245
|
+
provider === 'openrouter' ||
|
|
246
|
+
provider === 'anthropic' ||
|
|
247
|
+
provider === 'openai-codex' ||
|
|
248
|
+
provider.includes('azure') ||
|
|
249
|
+
FRONTIER_MODEL_PATTERN.test(`${provider}/${model.id}`) ||
|
|
250
|
+
isKnownFrontierEndpoint(model.baseUrl);
|
|
251
|
+
if (!frontier) return;
|
|
252
|
+
if (provider !== 'anthropic' && provider !== 'openai-codex') {
|
|
253
|
+
throw new FusionError(
|
|
254
|
+
`${slotLabel} route ${model.provider}/${model.id} is a frontier-model API channel; Fusion requires the Pi Anthropic or Codex subscription route`,
|
|
255
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
assertTrustedSubscriptionEndpoint(model, slotLabel, provider);
|
|
259
|
+
if (registry.isUsingOAuth === undefined) {
|
|
260
|
+
throw new FusionError(
|
|
261
|
+
`${slotLabel} route ${model.provider}/${model.id} cannot be admitted because ModelRegistry OAuth observation is unavailable`,
|
|
262
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (!registry.isUsingOAuth(model)) {
|
|
266
|
+
throw new FusionError(
|
|
267
|
+
`${slotLabel} route ${model.provider}/${model.id} is not using subscription OAuth; metered API credentials are forbidden for Fusion`,
|
|
268
|
+
{ code: 'model_unavailable', childCreated: false },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
166
273
|
function resolveSelection(
|
|
167
274
|
selection: FusionModelSelection,
|
|
168
275
|
slotLabel: string,
|
|
169
276
|
availableByKey: Map<string, Model<Api>>,
|
|
170
277
|
currentModel: Model<Api> | undefined,
|
|
171
278
|
thinkingLevel: FusionThinkingLevel,
|
|
279
|
+
registry: FusionModelRegistry,
|
|
172
280
|
): ResolvedFusionModel {
|
|
173
281
|
if (selection === CURRENT_MODEL_SELECTION) {
|
|
174
282
|
if (currentModel === undefined) {
|
|
@@ -188,6 +296,7 @@ function resolveSelection(
|
|
|
188
296
|
},
|
|
189
297
|
);
|
|
190
298
|
}
|
|
299
|
+
assertSubscriptionRoute(available, slotLabel, registry);
|
|
191
300
|
return {
|
|
192
301
|
selection,
|
|
193
302
|
source: 'current',
|
|
@@ -205,6 +314,7 @@ function resolveSelection(
|
|
|
205
314
|
childCreated: false,
|
|
206
315
|
});
|
|
207
316
|
}
|
|
317
|
+
assertSubscriptionRoute(model, slotLabel, registry);
|
|
208
318
|
return {
|
|
209
319
|
selection,
|
|
210
320
|
source: 'configured',
|
|
@@ -219,44 +329,23 @@ function resolveSelection(
|
|
|
219
329
|
export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFusionModels {
|
|
220
330
|
const availableByKey = modelIndex(input.modelRegistry.getAvailable());
|
|
221
331
|
const [first, second, third] = input.config.candidates;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
'candidate 1',
|
|
227
|
-
availableByKey,
|
|
228
|
-
input.currentModel,
|
|
229
|
-
input.thinkingLevel,
|
|
230
|
-
),
|
|
231
|
-
resolveSelection(
|
|
232
|
-
second,
|
|
233
|
-
'candidate 2',
|
|
234
|
-
availableByKey,
|
|
235
|
-
input.currentModel,
|
|
236
|
-
input.thinkingLevel,
|
|
237
|
-
),
|
|
238
|
-
resolveSelection(
|
|
239
|
-
third,
|
|
240
|
-
'candidate 3',
|
|
241
|
-
availableByKey,
|
|
242
|
-
input.currentModel,
|
|
243
|
-
input.thinkingLevel,
|
|
244
|
-
),
|
|
245
|
-
],
|
|
246
|
-
evaluator: resolveSelection(
|
|
247
|
-
input.config.evaluator,
|
|
248
|
-
'evaluator',
|
|
249
|
-
availableByKey,
|
|
250
|
-
input.currentModel,
|
|
251
|
-
input.thinkingLevel,
|
|
252
|
-
),
|
|
253
|
-
merger: resolveSelection(
|
|
254
|
-
input.config.merger,
|
|
255
|
-
'merger',
|
|
332
|
+
const resolve = (selection: FusionModelSelection, slot: string): ResolvedFusionModel =>
|
|
333
|
+
resolveSelection(
|
|
334
|
+
selection,
|
|
335
|
+
slot,
|
|
256
336
|
availableByKey,
|
|
257
337
|
input.currentModel,
|
|
258
338
|
input.thinkingLevel,
|
|
259
|
-
|
|
339
|
+
input.modelRegistry,
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
candidates: [
|
|
343
|
+
resolve(first, 'candidate 1'),
|
|
344
|
+
resolve(second, 'candidate 2'),
|
|
345
|
+
resolve(third, 'candidate 3'),
|
|
346
|
+
],
|
|
347
|
+
evaluator: resolve(input.config.evaluator, 'evaluator'),
|
|
348
|
+
merger: resolve(input.config.merger, 'merger'),
|
|
260
349
|
};
|
|
261
350
|
}
|
|
262
351
|
|