pi-background-tasks 0.7.2 → 0.7.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/PUBLISHING.md +10 -7
- package/README.md +79 -7
- package/TESTING.md +15 -5
- package/TEST_PLAN.md +12 -7
- package/package.json +10 -8
- package/src/core/attested-pi-run.ts +19 -35
- package/src/core/common.ts +124 -8
- package/src/core/durable-fs.ts +400 -0
- package/src/core/fusion/artifacts.ts +27 -55
- package/src/core/fusion/budget.ts +372 -0
- package/src/core/fusion/config.ts +4 -36
- package/src/core/fusion/context.ts +508 -47
- package/src/core/fusion/orchestrator.ts +48 -22
- package/src/core/fusion/pi-child.ts +74 -48
- package/src/core/fusion/prompts.ts +21 -7
- package/src/core/fusion/types.ts +307 -14
- package/src/core/pi-launch.ts +225 -0
- package/src/core/registry.ts +507 -56
- package/src/core/windows-taskkill.ts +250 -0
- package/src/fusion-child-extension.ts +11 -20
- package/src/fusion-extension.ts +9 -5
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
2
|
+
import { chmod, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
4
|
import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
|
|
6
5
|
import { sanitizePathSegment } from '../common.js';
|
|
6
|
+
import { replaceFileDurable } from '../durable-fs.js';
|
|
7
7
|
import {
|
|
8
8
|
EMPTY_FUSION_USAGE,
|
|
9
9
|
FUSION_MANIFEST_SCHEMA_VERSION,
|
|
10
10
|
FusionError,
|
|
11
|
+
cloneFusionUsage,
|
|
11
12
|
type FusionArtifactManifest,
|
|
12
13
|
type FusionArtifactRef,
|
|
13
14
|
type FusionAttemptArtifactRecord,
|
|
15
|
+
type FusionBudgetPlanV1,
|
|
14
16
|
type FusionCandidateId,
|
|
17
|
+
type FusionContextOmissionLedgerV1,
|
|
15
18
|
type FusionChildRunResult,
|
|
16
19
|
type FusionModelConfigV1,
|
|
17
20
|
type FusionSource,
|
|
@@ -83,18 +86,6 @@ function makeRunId(): string {
|
|
|
83
86
|
return `f${randomBytes(16).toString('hex')}`;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
function usageClone(usage: FusionUsage): FusionUsage {
|
|
87
|
-
const out: FusionUsage = {
|
|
88
|
-
input: usage.input,
|
|
89
|
-
output: usage.output,
|
|
90
|
-
cacheRead: usage.cacheRead,
|
|
91
|
-
cacheWrite: usage.cacheWrite,
|
|
92
|
-
totalTokens: usage.totalTokens,
|
|
93
|
-
};
|
|
94
|
-
if (usage.costTotal !== undefined) out.costTotal = usage.costTotal;
|
|
95
|
-
return out;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
89
|
function modelsForManifest(models: ResolvedFusionModels): MutableFusionArtifactManifest['models'] {
|
|
99
90
|
const first = models.candidates[0].qualifiedId;
|
|
100
91
|
const second = models.candidates[1].qualifiedId;
|
|
@@ -129,16 +120,6 @@ function canTransition(from: FusionState, to: FusionState): boolean {
|
|
|
129
120
|
return NEXT_STATES[from].includes(to);
|
|
130
121
|
}
|
|
131
122
|
|
|
132
|
-
function fsyncDirectory(path: string): void {
|
|
133
|
-
if (process.platform === 'win32') return;
|
|
134
|
-
const fd = openSync(path, 'r');
|
|
135
|
-
try {
|
|
136
|
-
fsyncSync(fd);
|
|
137
|
-
} finally {
|
|
138
|
-
closeSync(fd);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
123
|
function pathInside(parent: string, child: string): boolean {
|
|
143
124
|
const rel = relative(parent, child);
|
|
144
125
|
return (
|
|
@@ -150,34 +131,12 @@ function errorForArtifact(message: string): FusionError {
|
|
|
150
131
|
return new FusionError(message, { code: 'artifact_error', childCreated: false });
|
|
151
132
|
}
|
|
152
133
|
|
|
153
|
-
async function writeTempFile(absPath: string, data: Buffer | string): Promise<void> {
|
|
154
|
-
const handle = await open(absPath, 'wx', 0o600);
|
|
155
|
-
try {
|
|
156
|
-
await handle.writeFile(data);
|
|
157
|
-
await handle.sync();
|
|
158
|
-
} finally {
|
|
159
|
-
await handle.close();
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
134
|
async function writePrivateFile(
|
|
164
135
|
absPath: string,
|
|
165
136
|
data: Buffer | string,
|
|
166
137
|
): Promise<FusionArtifactRef> {
|
|
167
|
-
const dir = dirname(absPath);
|
|
168
|
-
const tmp = join(
|
|
169
|
-
dir,
|
|
170
|
-
`.${basename(absPath)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
|
|
171
|
-
);
|
|
172
138
|
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
173
|
-
|
|
174
|
-
await writeTempFile(tmp, data);
|
|
175
|
-
renameSync(tmp, absPath);
|
|
176
|
-
fsyncDirectory(dir);
|
|
177
|
-
} catch (error) {
|
|
178
|
-
await rm(tmp, { force: true });
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
139
|
+
await replaceFileDurable(absPath, data);
|
|
181
140
|
return { path: basename(absPath), byte_length: bytes.length, sha256: sha256Buffer(bytes) };
|
|
182
141
|
}
|
|
183
142
|
|
|
@@ -196,7 +155,7 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
|
|
|
196
155
|
cwd: manifest.cwd,
|
|
197
156
|
config: manifest.config,
|
|
198
157
|
models: manifest.models,
|
|
199
|
-
usage:
|
|
158
|
+
usage: cloneFusionUsage(manifest.usage),
|
|
200
159
|
attempts: [...manifest.attempts],
|
|
201
160
|
artifacts: { ...manifest.artifacts },
|
|
202
161
|
};
|
|
@@ -260,7 +219,7 @@ export class FusionArtifactStore {
|
|
|
260
219
|
cwd: options.cwd,
|
|
261
220
|
config: options.config,
|
|
262
221
|
models: modelsForManifest(options.models),
|
|
263
|
-
usage:
|
|
222
|
+
usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
|
|
264
223
|
attempts: [],
|
|
265
224
|
artifacts: {},
|
|
266
225
|
};
|
|
@@ -316,7 +275,7 @@ export class FusionArtifactStore {
|
|
|
316
275
|
|
|
317
276
|
async setUsage(usage: FusionUsage): Promise<void> {
|
|
318
277
|
await this.updateManifest((manifest) => {
|
|
319
|
-
manifest.usage =
|
|
278
|
+
manifest.usage = cloneFusionUsage(usage);
|
|
320
279
|
});
|
|
321
280
|
}
|
|
322
281
|
|
|
@@ -324,6 +283,20 @@ export class FusionArtifactStore {
|
|
|
324
283
|
await this.writeArtifact('canonical-input.json', serialized);
|
|
325
284
|
}
|
|
326
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Complete, source-ordered ledger of every omitted conversation event. Kept in
|
|
288
|
+
* a separate artifact so canonical input carries only compact run receipts
|
|
289
|
+
* while the full omission accounting stays locally auditable.
|
|
290
|
+
*/
|
|
291
|
+
async writeContextLedger(ledger: FusionContextOmissionLedgerV1): Promise<void> {
|
|
292
|
+
await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Route capacities and the pre-candidate whole-workflow feasibility decision. */
|
|
296
|
+
async writeBudgetPlan(plan: FusionBudgetPlanV1): Promise<void> {
|
|
297
|
+
await this.writeArtifact('budget-plan.json', canonicalJson(plan));
|
|
298
|
+
}
|
|
299
|
+
|
|
327
300
|
async writeBlindCandidates(serialized: string): Promise<void> {
|
|
328
301
|
await this.writeArtifact('blind-candidates.json', serialized);
|
|
329
302
|
}
|
|
@@ -372,7 +345,7 @@ export class FusionArtifactStore {
|
|
|
372
345
|
provider: input.result.provider,
|
|
373
346
|
model: input.result.model,
|
|
374
347
|
qualifiedId: input.result.qualifiedId,
|
|
375
|
-
usage:
|
|
348
|
+
usage: cloneFusionUsage(input.result.usage),
|
|
376
349
|
};
|
|
377
350
|
if (input.result.slot !== undefined) record.slot = input.result.slot;
|
|
378
351
|
manifest.attempts.push(record);
|
|
@@ -403,12 +376,11 @@ export class FusionArtifactStore {
|
|
|
403
376
|
response_path: responseRef.path,
|
|
404
377
|
error: input.error,
|
|
405
378
|
};
|
|
406
|
-
if (partialResponseRef !== undefined)
|
|
407
|
-
record.partial_response_path = partialResponseRef.path;
|
|
379
|
+
if (partialResponseRef !== undefined) record.partial_response_path = partialResponseRef.path;
|
|
408
380
|
if (input.provider !== undefined) record.provider = input.provider;
|
|
409
381
|
if (input.model !== undefined) record.model = input.model;
|
|
410
382
|
if (input.qualifiedId !== undefined) record.qualifiedId = input.qualifiedId;
|
|
411
|
-
if (input.usage !== undefined) record.usage =
|
|
383
|
+
if (input.usage !== undefined) record.usage = cloneFusionUsage(input.usage);
|
|
412
384
|
if (input.slot !== undefined) record.slot = input.slot;
|
|
413
385
|
manifest.attempts.push(record);
|
|
414
386
|
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FUSION_BUDGET_PLAN_SCHEMA_VERSION,
|
|
3
|
+
FusionError,
|
|
4
|
+
type FusionBudgetErrorDetail,
|
|
5
|
+
type FusionBudgetPlanV1,
|
|
6
|
+
type FusionBudgetPolicyDescriptor,
|
|
7
|
+
type FusionBudgetStage,
|
|
8
|
+
type FusionRouteCapacity,
|
|
9
|
+
type FusionStage,
|
|
10
|
+
type FusionStageBudgetPlanEntry,
|
|
11
|
+
type ResolvedFusionModel,
|
|
12
|
+
type ResolvedFusionModels,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Conservative lower bound on UTF-8 bytes per input token.
|
|
17
|
+
*
|
|
18
|
+
* Token upper bound = ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR).
|
|
19
|
+
*
|
|
20
|
+
* Across 159 real large Fusion prompts (50 KB–1.4 MB) recorded in
|
|
21
|
+
* `.pi/fusion/**\/manifest.json`, the smallest observed ratio was 3.552 bytes
|
|
22
|
+
* per reported input token. A divisor of 2 therefore leaves roughly a 1.7x
|
|
23
|
+
* margin against the densest real prompt and still bounds pathological input
|
|
24
|
+
* such as dense CJK (3 UTF-8 bytes producing at most ~1.5 tokens) or long runs
|
|
25
|
+
* of punctuation. It is not an estimate of typical usage; it is a ceiling.
|
|
26
|
+
*/
|
|
27
|
+
export const FUSION_BYTES_PER_TOKEN_DIVISOR = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enforced maximum size of one child's response, measured in **JSON-rendered
|
|
31
|
+
* transfer bytes** — the bytes the response actually contributes when a later
|
|
32
|
+
* stage embeds it, escaping included.
|
|
33
|
+
*
|
|
34
|
+
* Measuring the rendered form rather than the raw form removes the need to
|
|
35
|
+
* guess an escaping factor: quotes, backslashes, and newlines expand 2x and
|
|
36
|
+
* control characters up to 6x, so a raw-byte contract would not bound the
|
|
37
|
+
* embedded size. `assertChildOutputWithinContract` rejects a response that
|
|
38
|
+
* exceeds its stage bound, so a later stage can never be handed more embedded
|
|
39
|
+
* text than is budgeted here. Nothing is truncated to fit: an oversized
|
|
40
|
+
* response is a loud failure.
|
|
41
|
+
*
|
|
42
|
+
* Sized above the largest real responses recorded across `.pi/fusion`:
|
|
43
|
+
* candidate 45,434 B, evaluator 54,829 B, merged 56,846 B.
|
|
44
|
+
*/
|
|
45
|
+
export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
|
|
46
|
+
export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
47
|
+
export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
48
|
+
|
|
49
|
+
/** `boundedEvaluationErrors` caps repair diagnostics far below this. */
|
|
50
|
+
export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tokens reserved so a child can emit a response up to its byte contract.
|
|
54
|
+
*
|
|
55
|
+
* Derived from the largest output contract through the same conservative
|
|
56
|
+
* byte-to-token conversion used to measure prompts, so it cannot be understated.
|
|
57
|
+
*/
|
|
58
|
+
export const FUSION_RESERVED_OUTPUT_TOKENS = Math.ceil(
|
|
59
|
+
FUSION_MERGE_MAX_OUTPUT_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Upper bound on Pi/provider framing that is not represented in the prompt
|
|
64
|
+
* bytes Fusion renders: the child system prompt is passed via argv (counted
|
|
65
|
+
* separately below), but chat framing, tool-free scaffolding, and provider
|
|
66
|
+
* envelope overhead are not visible to this package.
|
|
67
|
+
*/
|
|
68
|
+
export const FUSION_FRAMING_RESERVE_TOKENS = 4_096;
|
|
69
|
+
|
|
70
|
+
/** Additional margin for provider-side tokenizer differences. */
|
|
71
|
+
export const FUSION_SAFETY_RESERVE_TOKENS = 4_096;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fixed structural overhead of the blind-candidate and repair wrappers: schema
|
|
75
|
+
* version strings, candidate_id keys, JSON punctuation, and the evaluation
|
|
76
|
+
* object's own keys. These are constant-size, not content-dependent; 16 KiB is
|
|
77
|
+
* an order of magnitude above the real wrapper size.
|
|
78
|
+
*/
|
|
79
|
+
export const FUSION_WRAPPER_OVERHEAD_BYTES = 16 * 1024;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bytes of downstream growth the canonical input must leave room for.
|
|
83
|
+
*
|
|
84
|
+
* The widest stage prompt is the evaluation repair, which embeds the canonical
|
|
85
|
+
* input plus three candidate answers, the invalid evaluator output, and bounded
|
|
86
|
+
* validation errors. The merge prompt is strictly smaller.
|
|
87
|
+
*
|
|
88
|
+
* Because the output contracts above are enforced against JSON-rendered bytes,
|
|
89
|
+
* this is an exact sum rather than a raw size inflated by an estimated escaping
|
|
90
|
+
* factor. No content can expand past it, and the exact rendered prompt is still
|
|
91
|
+
* re-measured before every spawn as defence in depth.
|
|
92
|
+
*/
|
|
93
|
+
export const FUSION_DOWNSTREAM_RESERVE_BYTES =
|
|
94
|
+
3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES +
|
|
95
|
+
FUSION_EVALUATION_MAX_OUTPUT_BYTES +
|
|
96
|
+
FUSION_DIAGNOSTICS_MAX_BYTES +
|
|
97
|
+
FUSION_WRAPPER_OVERHEAD_BYTES;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Tokens withheld from the canonical input for that growth.
|
|
101
|
+
*
|
|
102
|
+
* This converts the reserve through the same byte-to-token function used to
|
|
103
|
+
* measure prompts. Reserving output *tokens* directly would understate the cost
|
|
104
|
+
* of re-embedding those bytes by the ratio between a provider's real
|
|
105
|
+
* tokenization and this conservative ceiling.
|
|
106
|
+
*/
|
|
107
|
+
export const FUSION_DOWNSTREAM_RESERVE_TOKENS = Math.ceil(
|
|
108
|
+
FUSION_DOWNSTREAM_RESERVE_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** Minimum usable canonical-input room a configured route must still offer. */
|
|
112
|
+
export const FUSION_MIN_CANONICAL_INPUT_TOKENS = 8_192;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Smallest context window that can host the complete workflow under this policy.
|
|
116
|
+
*
|
|
117
|
+
* This is a consequence of uniformly conservative accounting, not a preference
|
|
118
|
+
* for large models: the downstream reserve, the output reserve, framing, safety,
|
|
119
|
+
* and a usable amount of canonical input must all fit. Routes below this are
|
|
120
|
+
* rejected at configuration time with an actionable error rather than being
|
|
121
|
+
* silently accepted and failing later at the provider.
|
|
122
|
+
*/
|
|
123
|
+
export const FUSION_MIN_CONTEXT_WINDOW_TOKENS =
|
|
124
|
+
FUSION_DOWNSTREAM_RESERVE_TOKENS +
|
|
125
|
+
FUSION_MIN_CANONICAL_INPUT_TOKENS +
|
|
126
|
+
FUSION_RESERVED_OUTPUT_TOKENS +
|
|
127
|
+
FUSION_FRAMING_RESERVE_TOKENS +
|
|
128
|
+
FUSION_SAFETY_RESERVE_TOKENS;
|
|
129
|
+
|
|
130
|
+
export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
|
|
131
|
+
id: 'fusion-budget-policy-v1',
|
|
132
|
+
bytes_per_token_divisor: FUSION_BYTES_PER_TOKEN_DIVISOR,
|
|
133
|
+
reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
|
|
134
|
+
framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
|
|
135
|
+
safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
|
|
136
|
+
downstream_reserve_bytes: FUSION_DOWNSTREAM_RESERVE_BYTES,
|
|
137
|
+
downstream_reserve_tokens: FUSION_DOWNSTREAM_RESERVE_TOKENS,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const REMEDIATION: readonly string[] = Object.freeze([
|
|
141
|
+
'Start a fresh Pi conversation, or run Fusion earlier in the session.',
|
|
142
|
+
'Provide a shorter, self-contained fusion_brainstorm prompt.',
|
|
143
|
+
'Restate only the required prior findings as visible conversation text.',
|
|
144
|
+
'Configure a larger-context model for the limiting Fusion slot via /fusion-models.',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
export function fusionTokenUpperBound(utf8Bytes: number): number {
|
|
148
|
+
return Math.ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Enforced response-size contract for one stage, in UTF-8 bytes. */
|
|
152
|
+
export function fusionOutputContractBytes(stage: FusionStage): number {
|
|
153
|
+
if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
|
|
154
|
+
if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
|
|
155
|
+
return FUSION_MERGE_MAX_OUTPUT_BYTES;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reject a child response larger than its stage contract.
|
|
160
|
+
*
|
|
161
|
+
* This is what makes the downstream reserve a guarantee rather than a hope: a
|
|
162
|
+
* later stage can never embed more bytes than were budgeted. The oversized text
|
|
163
|
+
* is never sliced and never forwarded; the run fails loudly instead.
|
|
164
|
+
*/
|
|
165
|
+
export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
|
|
166
|
+
// Measure what the response costs once embedded, escaping included, so the
|
|
167
|
+
// downstream reserve is an exact bound rather than an estimate.
|
|
168
|
+
const bytes = Buffer.byteLength(JSON.stringify(text), 'utf8');
|
|
169
|
+
const allowed = fusionOutputContractBytes(stage);
|
|
170
|
+
if (bytes <= allowed) return;
|
|
171
|
+
throw new FusionError(
|
|
172
|
+
`fusion ${stage} response is ${String(bytes)} JSON-rendered bytes, exceeding the ${String(allowed)}-byte output contract for that stage; the response is preserved in the run artifacts and is not forwarded or truncated`,
|
|
173
|
+
{ code: 'child_output_cap', stage, childCreated: true },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requirePositiveContextWindow(model: ResolvedFusionModel, role: string): number {
|
|
178
|
+
const value = model.contextWindow;
|
|
179
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
180
|
+
throw new FusionError(
|
|
181
|
+
`fusion ${role} route ${model.qualifiedId} has no usable context window capacity`,
|
|
182
|
+
{ code: 'model_capacity_unknown', childCreated: false },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function routeCapacity(
|
|
189
|
+
model: ResolvedFusionModel,
|
|
190
|
+
role: FusionRouteCapacity['role'],
|
|
191
|
+
): FusionRouteCapacity {
|
|
192
|
+
const contextWindow = requirePositiveContextWindow(model, role);
|
|
193
|
+
const allowed =
|
|
194
|
+
contextWindow -
|
|
195
|
+
FUSION_RESERVED_OUTPUT_TOKENS -
|
|
196
|
+
FUSION_FRAMING_RESERVE_TOKENS -
|
|
197
|
+
FUSION_SAFETY_RESERVE_TOKENS;
|
|
198
|
+
// The route must hold the downstream reserve plus a usable amount of canonical
|
|
199
|
+
// input, otherwise the configured panel can never complete a workflow.
|
|
200
|
+
if (allowed < FUSION_DOWNSTREAM_RESERVE_TOKENS + FUSION_MIN_CANONICAL_INPUT_TOKENS) {
|
|
201
|
+
throw new FusionError(
|
|
202
|
+
`fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but the Fusion workflow requires at least ${String(FUSION_MIN_CONTEXT_WINDOW_TOKENS)} tokens per configured route: ${String(FUSION_RESERVED_OUTPUT_TOKENS)} output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_DOWNSTREAM_RESERVE_TOKENS)} for evaluator/repair/merger expansion + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable canonical input. Choose a larger-context model for this slot with /fusion-models.`,
|
|
203
|
+
{ code: 'model_capacity_unknown', childCreated: false },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
role,
|
|
208
|
+
provider: model.provider,
|
|
209
|
+
model: model.model,
|
|
210
|
+
qualified_id: model.qualifiedId,
|
|
211
|
+
context_window_tokens: contextWindow,
|
|
212
|
+
reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
|
|
213
|
+
framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
|
|
214
|
+
safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
|
|
215
|
+
allowed_input_tokens: allowed,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function fusionRouteCapacities(models: ResolvedFusionModels): readonly FusionRouteCapacity[] {
|
|
220
|
+
return [
|
|
221
|
+
routeCapacity(models.candidates[0], 'candidate-1'),
|
|
222
|
+
routeCapacity(models.candidates[1], 'candidate-2'),
|
|
223
|
+
routeCapacity(models.candidates[2], 'candidate-3'),
|
|
224
|
+
routeCapacity(models.evaluator, 'evaluator'),
|
|
225
|
+
routeCapacity(models.merger, 'merger'),
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The limiting route is the configured participant with the smallest input
|
|
231
|
+
* budget — never the largest-context model. Ties resolve to the first route in
|
|
232
|
+
* declaration order so the plan is deterministic.
|
|
233
|
+
*/
|
|
234
|
+
export function fusionLimitingRoute(
|
|
235
|
+
routes: readonly FusionRouteCapacity[],
|
|
236
|
+
): FusionRouteCapacity {
|
|
237
|
+
let limiting: FusionRouteCapacity | undefined;
|
|
238
|
+
for (const route of routes) {
|
|
239
|
+
if (limiting === undefined || route.allowed_input_tokens < limiting.allowed_input_tokens) {
|
|
240
|
+
limiting = route;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (limiting === undefined) {
|
|
244
|
+
throw new FusionError('fusion budget planning received no configured routes', {
|
|
245
|
+
code: 'model_capacity_unknown',
|
|
246
|
+
childCreated: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return limiting;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class FusionBudget {
|
|
253
|
+
readonly routes: readonly FusionRouteCapacity[];
|
|
254
|
+
readonly limiting: FusionRouteCapacity;
|
|
255
|
+
private readonly contextPolicyId: string;
|
|
256
|
+
|
|
257
|
+
constructor(models: ResolvedFusionModels, contextPolicyId: string) {
|
|
258
|
+
this.routes = fusionRouteCapacities(models);
|
|
259
|
+
this.limiting = fusionLimitingRoute(this.routes);
|
|
260
|
+
this.contextPolicyId = contextPolicyId;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Full input budget of the limiting route, in tokens. */
|
|
264
|
+
get allowedInputTokens(): number {
|
|
265
|
+
return this.limiting.allowed_input_tokens;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Budget for the canonical input alone, holding back the derived reserve that
|
|
270
|
+
* downstream evaluator/repair/merger expansion provably needs.
|
|
271
|
+
*/
|
|
272
|
+
get allowedCanonicalInputTokens(): number {
|
|
273
|
+
return this.allowedInputTokens - FUSION_DOWNSTREAM_RESERVE_TOKENS;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private failure(
|
|
277
|
+
stage: FusionBudgetStage,
|
|
278
|
+
measurementKind: FusionBudgetErrorDetail['measurement_kind'],
|
|
279
|
+
utf8Bytes: number,
|
|
280
|
+
allowedTokens: number,
|
|
281
|
+
label: string,
|
|
282
|
+
): FusionError {
|
|
283
|
+
const tokens = fusionTokenUpperBound(utf8Bytes);
|
|
284
|
+
const budget: FusionBudgetErrorDetail = {
|
|
285
|
+
budget_stage: stage,
|
|
286
|
+
measurement_kind: measurementKind,
|
|
287
|
+
measured_utf8_bytes: utf8Bytes,
|
|
288
|
+
measured_input_tokens_upper_bound: tokens,
|
|
289
|
+
allowed_input_tokens: allowedTokens,
|
|
290
|
+
limiting_model: {
|
|
291
|
+
provider: this.limiting.provider,
|
|
292
|
+
model: this.limiting.model,
|
|
293
|
+
qualified_id: this.limiting.qualified_id,
|
|
294
|
+
context_window_tokens: this.limiting.context_window_tokens,
|
|
295
|
+
},
|
|
296
|
+
context_policy_id: this.contextPolicyId,
|
|
297
|
+
remediation: REMEDIATION,
|
|
298
|
+
};
|
|
299
|
+
const message =
|
|
300
|
+
`fusion ${label} exceeds the safe input budget before child creation: ` +
|
|
301
|
+
`measured ${String(utf8Bytes)} UTF-8 bytes (<= ${String(tokens)} input tokens) ` +
|
|
302
|
+
`against ${String(allowedTokens)} allowed input tokens for the limiting configured model ` +
|
|
303
|
+
`${this.limiting.qualified_id} (context window ${String(this.limiting.context_window_tokens)} tokens, ` +
|
|
304
|
+
`reserving ${String(this.limiting.reserved_output_tokens)} output + ` +
|
|
305
|
+
`${String(this.limiting.framing_reserve_tokens)} framing + ` +
|
|
306
|
+
`${String(this.limiting.safety_reserve_tokens)} safety tokens). ` +
|
|
307
|
+
`Remediation: ${REMEDIATION.join(' ')}`;
|
|
308
|
+
return new FusionError(message, {
|
|
309
|
+
code: 'prompt_budget_exceeded',
|
|
310
|
+
childCreated: false,
|
|
311
|
+
budget,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Whole-DAG feasibility check run before the first candidate spawns. Proving the
|
|
317
|
+
* canonical input fits within its reserved share proves every downstream stage
|
|
318
|
+
* has room for its expansion, because the reserved remainder exceeds the
|
|
319
|
+
* largest possible candidate/evaluation growth by construction.
|
|
320
|
+
*/
|
|
321
|
+
assertBaseContext(canonicalInputSerialized: string, systemPromptBytes: number): void {
|
|
322
|
+
const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
|
|
323
|
+
if (fusionTokenUpperBound(bytes) > this.allowedCanonicalInputTokens) {
|
|
324
|
+
throw this.failure(
|
|
325
|
+
'candidate',
|
|
326
|
+
'worst_case_envelope',
|
|
327
|
+
bytes,
|
|
328
|
+
this.allowedCanonicalInputTokens,
|
|
329
|
+
'conversation projection plus request',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Exact preflight for one rendered stage prompt, measured on the same bytes
|
|
336
|
+
* that will be written to the child's stdin and persisted as the artifact.
|
|
337
|
+
*/
|
|
338
|
+
assertStagePrompt(stage: FusionBudgetStage, systemPrompt: string, userPrompt: string): void {
|
|
339
|
+
const bytes =
|
|
340
|
+
Buffer.byteLength(systemPrompt, 'utf8') + Buffer.byteLength(userPrompt, 'utf8');
|
|
341
|
+
if (fusionTokenUpperBound(bytes) > this.allowedInputTokens) {
|
|
342
|
+
throw this.failure(
|
|
343
|
+
stage,
|
|
344
|
+
'rendered_prompt',
|
|
345
|
+
bytes,
|
|
346
|
+
this.allowedInputTokens,
|
|
347
|
+
`${stage} prompt`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
plan(canonicalInputSerialized: string, systemPromptBytes: number): FusionBudgetPlanV1 {
|
|
353
|
+
const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
|
|
354
|
+
const tokens = fusionTokenUpperBound(bytes);
|
|
355
|
+
const base: FusionStageBudgetPlanEntry = {
|
|
356
|
+
budget_stage: 'candidate',
|
|
357
|
+
measurement_kind: 'worst_case_envelope',
|
|
358
|
+
measured_utf8_bytes: bytes,
|
|
359
|
+
measured_input_tokens_upper_bound: tokens,
|
|
360
|
+
allowed_input_tokens: this.allowedCanonicalInputTokens,
|
|
361
|
+
limiting_qualified_id: this.limiting.qualified_id,
|
|
362
|
+
slack_tokens: this.allowedCanonicalInputTokens - tokens,
|
|
363
|
+
};
|
|
364
|
+
return {
|
|
365
|
+
schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
|
|
366
|
+
policy: FUSION_BUDGET_POLICY,
|
|
367
|
+
routes: this.routes,
|
|
368
|
+
limiting_qualified_id: this.limiting.qualified_id,
|
|
369
|
+
base_context: base,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
4
3
|
import { basename, dirname, join } from 'node:path';
|
|
5
4
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
6
5
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
7
6
|
import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
|
|
7
|
+
import { replaceFileDurable } from '../durable-fs.js';
|
|
8
8
|
import {
|
|
9
9
|
FUSION_MODEL_CONFIG_SCHEMA_VERSION,
|
|
10
10
|
FusionError,
|
|
@@ -260,25 +260,6 @@ export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFu
|
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
async function fsyncFile(path: string): Promise<void> {
|
|
264
|
-
const handle = await open(path, 'r');
|
|
265
|
-
try {
|
|
266
|
-
await handle.sync();
|
|
267
|
-
} finally {
|
|
268
|
-
await handle.close();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async function fsyncDirectory(path: string): Promise<void> {
|
|
273
|
-
if (process.platform === 'win32') return;
|
|
274
|
-
const fd = openSync(path, 'r');
|
|
275
|
-
try {
|
|
276
|
-
fsyncSync(fd);
|
|
277
|
-
} finally {
|
|
278
|
-
closeSync(fd);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
263
|
async function delay(ms: number): Promise<void> {
|
|
283
264
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
284
265
|
}
|
|
@@ -348,20 +329,7 @@ export async function saveFusionModelConfig(
|
|
|
348
329
|
childCreated: false,
|
|
349
330
|
});
|
|
350
331
|
}
|
|
351
|
-
|
|
352
|
-
dir,
|
|
353
|
-
`.${basename(path)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
|
|
354
|
-
);
|
|
355
|
-
const text = prettyConfig(config);
|
|
356
|
-
try {
|
|
357
|
-
await writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
|
|
358
|
-
await fsyncFile(tmp);
|
|
359
|
-
renameSync(tmp, path);
|
|
360
|
-
await fsyncDirectory(dir);
|
|
361
|
-
} catch (error) {
|
|
362
|
-
await rm(tmp, { force: true });
|
|
363
|
-
throw error;
|
|
364
|
-
}
|
|
332
|
+
await replaceFileDurable(path, prettyConfig(config));
|
|
365
333
|
return revisionForPath(path);
|
|
366
334
|
});
|
|
367
335
|
}
|