pi-background-tasks 0.6.0 → 0.7.2
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 +15 -15
- package/README.md +78 -6
- package/TESTING.md +28 -12
- package/TEST_PLAN.md +21 -12
- package/extensions/background-tasks.ts +1 -1
- package/extensions/fusion-child.ts +1 -0
- package/package.json +16 -10
- package/src/core/attested-pi-run.ts +619 -0
- package/src/core/common.ts +599 -432
- package/src/core/extension-api.ts +548 -0
- package/src/core/fusion/artifacts.ts +453 -0
- package/src/core/fusion/config.ts +371 -0
- package/src/core/fusion/context.ts +179 -0
- package/src/core/fusion/evaluation.ts +362 -0
- package/src/core/fusion/orchestrator.ts +595 -0
- package/src/core/fusion/pi-child.ts +900 -0
- package/src/core/fusion/prompts.ts +155 -0
- package/src/core/fusion/types.ts +289 -0
- package/src/core/registry.ts +1352 -786
- package/src/core/update-check.ts +69 -63
- package/src/extension.ts +880 -524
- package/src/fusion-child-extension.ts +100 -0
- package/src/fusion-extension.ts +632 -0
- package/src/testing/normalize.ts +22 -3
- package/src/ui/background-tasks-manager.ts +703 -613
- package/src/ui/fusion-model-selector.ts +322 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { randomBytes as nodeRandomBytes } from 'node:crypto';
|
|
2
|
+
import { parseJsonText } from '../common.js';
|
|
3
|
+
import {
|
|
4
|
+
FusionArtifactStore,
|
|
5
|
+
type CreateFusionArtifactStoreOptions,
|
|
6
|
+
type RecordFusionFailedAttemptInput,
|
|
7
|
+
} from './artifacts.js';
|
|
8
|
+
import {
|
|
9
|
+
boundedEvaluationErrors,
|
|
10
|
+
formatEvaluationErrors,
|
|
11
|
+
validateFusionEvaluation,
|
|
12
|
+
} from './evaluation.js';
|
|
13
|
+
import { FusionChildRunError, runPiChild, type RunPiChildOptions } from './pi-child.js';
|
|
14
|
+
import {
|
|
15
|
+
FUSION_CANDIDATE_SYSTEM_PROMPT,
|
|
16
|
+
FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
|
|
17
|
+
FUSION_EVALUATOR_SYSTEM_PROMPT,
|
|
18
|
+
FUSION_MERGER_SYSTEM_PROMPT,
|
|
19
|
+
buildBlindEvaluationInput,
|
|
20
|
+
buildCandidatePrompt,
|
|
21
|
+
buildEvaluationPrompt,
|
|
22
|
+
buildEvaluationRepairPrompt,
|
|
23
|
+
buildMergeInput,
|
|
24
|
+
buildMergePrompt,
|
|
25
|
+
type AnonymousFusionCandidate,
|
|
26
|
+
} from './prompts.js';
|
|
27
|
+
import {
|
|
28
|
+
FUSION_RESULT_SCHEMA_VERSION,
|
|
29
|
+
FusionError,
|
|
30
|
+
type FusionCanonicalInputV1,
|
|
31
|
+
type FusionCandidateId,
|
|
32
|
+
type FusionChildRunResult,
|
|
33
|
+
type FusionErrorDetails,
|
|
34
|
+
type FusionEvaluationV1,
|
|
35
|
+
type FusionModelConfigV1,
|
|
36
|
+
type FusionProgressEvent,
|
|
37
|
+
type FusionRunResult,
|
|
38
|
+
type FusionSource,
|
|
39
|
+
type FusionStage,
|
|
40
|
+
type FusionUsage,
|
|
41
|
+
type ResolvedFusionModel,
|
|
42
|
+
type ResolvedFusionModels,
|
|
43
|
+
} from './types.js';
|
|
44
|
+
|
|
45
|
+
export type FusionChildRunner = (options: RunPiChildOptions) => Promise<FusionChildRunResult>;
|
|
46
|
+
export type FusionProgressSink = (event: FusionProgressEvent) => void;
|
|
47
|
+
export type FusionRandomBytes = (size: number) => Buffer;
|
|
48
|
+
|
|
49
|
+
type CandidateSlot = 1 | 2 | 3;
|
|
50
|
+
|
|
51
|
+
export interface FusionWorkflowInput {
|
|
52
|
+
source: FusionSource;
|
|
53
|
+
cwd: string;
|
|
54
|
+
sessionId?: string | undefined;
|
|
55
|
+
canonicalInput: FusionCanonicalInputV1;
|
|
56
|
+
canonicalInputSerialized: string;
|
|
57
|
+
config: FusionModelConfigV1;
|
|
58
|
+
models: ResolvedFusionModels;
|
|
59
|
+
signal?: AbortSignal | undefined;
|
|
60
|
+
onProgress?: FusionProgressSink | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FusionOrchestratorOptions {
|
|
64
|
+
childRunner?: FusionChildRunner | undefined;
|
|
65
|
+
randomBytes?: FusionRandomBytes | undefined;
|
|
66
|
+
now?: () => Date;
|
|
67
|
+
createArtifactStore?:
|
|
68
|
+
| ((options: CreateFusionArtifactStoreOptions) => Promise<FusionArtifactStore>)
|
|
69
|
+
| undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface CandidateResult {
|
|
73
|
+
slot: CandidateSlot;
|
|
74
|
+
result: FusionChildRunResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface EvaluationAttemptResult {
|
|
78
|
+
result: FusionChildRunResult;
|
|
79
|
+
evaluation: FusionEvaluationV1 | undefined;
|
|
80
|
+
errors: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emptyUsage(): FusionUsage {
|
|
84
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addUsage(target: FusionUsage, delta: FusionUsage): void {
|
|
88
|
+
target.input += delta.input;
|
|
89
|
+
target.output += delta.output;
|
|
90
|
+
target.cacheRead += delta.cacheRead;
|
|
91
|
+
target.cacheWrite += delta.cacheWrite;
|
|
92
|
+
target.totalTokens += delta.totalTokens;
|
|
93
|
+
if (delta.costTotal !== undefined) target.costTotal = (target.costTotal ?? 0) + delta.costTotal;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addFailedChildUsage(target: FusionUsage, error: unknown): void {
|
|
97
|
+
if (error instanceof FusionChildRunError) addUsage(target, error.usage);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function errorText(error: unknown): string {
|
|
101
|
+
return error instanceof Error ? error.message : String(error);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function asFusionError(error: unknown, artifactDir: string, messageOverride?: string): FusionError {
|
|
105
|
+
if (error instanceof FusionError) {
|
|
106
|
+
const details: FusionErrorDetails = {
|
|
107
|
+
code: error.code,
|
|
108
|
+
artifactDir,
|
|
109
|
+
transient: error.transient,
|
|
110
|
+
childCreated: error.childCreated,
|
|
111
|
+
};
|
|
112
|
+
if (error.stage !== undefined) details.stage = error.stage;
|
|
113
|
+
if (error.slot !== undefined) details.slot = error.slot;
|
|
114
|
+
if (error.attempt !== undefined) details.attempt = error.attempt;
|
|
115
|
+
return new FusionError(messageOverride ?? error.message, details);
|
|
116
|
+
}
|
|
117
|
+
return new FusionError(messageOverride ?? errorText(error), {
|
|
118
|
+
code: 'orchestration_failed',
|
|
119
|
+
artifactDir,
|
|
120
|
+
childCreated: false,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function withTerminalArtifactFailure(
|
|
125
|
+
error: unknown,
|
|
126
|
+
artifactDir: string,
|
|
127
|
+
artifactError: unknown,
|
|
128
|
+
): FusionError {
|
|
129
|
+
const message = `${errorText(error)}; additionally failed to write terminal fusion artifacts: ${errorText(artifactError)}`;
|
|
130
|
+
return asFusionError(error, artifactDir, message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function recordFailureInput(
|
|
134
|
+
error: unknown,
|
|
135
|
+
stage: FusionStage,
|
|
136
|
+
slot: CandidateSlot | undefined,
|
|
137
|
+
attempt: number,
|
|
138
|
+
prompt: string,
|
|
139
|
+
responseKind: 'md' | 'txt',
|
|
140
|
+
): RecordFusionFailedAttemptInput {
|
|
141
|
+
if (error instanceof FusionChildRunError) {
|
|
142
|
+
const base: RecordFusionFailedAttemptInput = {
|
|
143
|
+
stage,
|
|
144
|
+
attempt,
|
|
145
|
+
prompt,
|
|
146
|
+
events: error.events,
|
|
147
|
+
partialResponse: error.response,
|
|
148
|
+
stderr: error.stderr,
|
|
149
|
+
error: error.message,
|
|
150
|
+
status: error.code === 'child_cancelled' ? 'cancelled' : 'failed',
|
|
151
|
+
responseKind,
|
|
152
|
+
usage: error.usage,
|
|
153
|
+
};
|
|
154
|
+
if (slot !== undefined) base.slot = slot;
|
|
155
|
+
if (error.provider !== undefined) base.provider = error.provider;
|
|
156
|
+
if (error.modelName !== undefined) base.model = error.modelName;
|
|
157
|
+
if (error.qualifiedId !== undefined) base.qualifiedId = error.qualifiedId;
|
|
158
|
+
return base;
|
|
159
|
+
}
|
|
160
|
+
const base: RecordFusionFailedAttemptInput = {
|
|
161
|
+
stage,
|
|
162
|
+
attempt,
|
|
163
|
+
prompt,
|
|
164
|
+
events: Buffer.alloc(0),
|
|
165
|
+
partialResponse: Buffer.alloc(0),
|
|
166
|
+
stderr: Buffer.alloc(0),
|
|
167
|
+
error: errorText(error),
|
|
168
|
+
status:
|
|
169
|
+
error instanceof FusionError && error.code === 'child_cancelled' ? 'cancelled' : 'failed',
|
|
170
|
+
responseKind,
|
|
171
|
+
};
|
|
172
|
+
if (slot !== undefined) base.slot = slot;
|
|
173
|
+
return base;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function retryableSpawn(error: unknown, attempt: number): boolean {
|
|
177
|
+
if (!(error instanceof FusionError)) return false;
|
|
178
|
+
return (
|
|
179
|
+
attempt === 1 && error.code === 'child_spawn_failed' && error.transient && !error.childCreated
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function childOptions(
|
|
184
|
+
input: FusionWorkflowInput,
|
|
185
|
+
model: ResolvedFusionModel,
|
|
186
|
+
stage: FusionStage,
|
|
187
|
+
attempt: number,
|
|
188
|
+
systemPrompt: string,
|
|
189
|
+
userPrompt: string,
|
|
190
|
+
signal: AbortSignal,
|
|
191
|
+
slot?: CandidateSlot,
|
|
192
|
+
): RunPiChildOptions {
|
|
193
|
+
const out: RunPiChildOptions = {
|
|
194
|
+
stage,
|
|
195
|
+
attempt,
|
|
196
|
+
cwd: input.cwd,
|
|
197
|
+
model,
|
|
198
|
+
systemPrompt,
|
|
199
|
+
userPrompt,
|
|
200
|
+
signal,
|
|
201
|
+
};
|
|
202
|
+
if (slot !== undefined) out.slot = slot;
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseEvaluationAttempt(text: string): {
|
|
207
|
+
evaluation: FusionEvaluationV1 | undefined;
|
|
208
|
+
errors: readonly string[];
|
|
209
|
+
} {
|
|
210
|
+
let parsed: unknown;
|
|
211
|
+
try {
|
|
212
|
+
parsed = parseJsonText(text);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return {
|
|
215
|
+
evaluation: undefined,
|
|
216
|
+
errors: [`evaluation output must be JSON only: ${errorText(error)}`],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const result = validateFusionEvaluation(parsed);
|
|
220
|
+
if (result.ok) return { evaluation: result.value, errors: [] };
|
|
221
|
+
return { evaluation: undefined, errors: result.errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function randomIndex(limit: number, randomBytes: FusionRandomBytes): number {
|
|
225
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 0xffffffff) {
|
|
226
|
+
throw new FusionError(`invalid random limit ${String(limit)}`, {
|
|
227
|
+
code: 'orchestration_failed',
|
|
228
|
+
childCreated: false,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const range = 0x100000000;
|
|
232
|
+
const ceiling = range - (range % limit);
|
|
233
|
+
for (;;) {
|
|
234
|
+
const bytes = randomBytes(4);
|
|
235
|
+
if (bytes.length < 4) {
|
|
236
|
+
throw new FusionError('random byte source returned too few bytes', {
|
|
237
|
+
code: 'orchestration_failed',
|
|
238
|
+
childCreated: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const value = bytes.readUInt32BE(0);
|
|
242
|
+
if (value < ceiling) return value % limit;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function shuffledSlots(randomBytes: FusionRandomBytes): CandidateSlot[] {
|
|
247
|
+
const slots: CandidateSlot[] = [1, 2, 3];
|
|
248
|
+
for (let i = slots.length - 1; i > 0; i--) {
|
|
249
|
+
const j = randomIndex(i + 1, randomBytes);
|
|
250
|
+
const left = slots[i];
|
|
251
|
+
const right = slots[j];
|
|
252
|
+
if (left === undefined || right === undefined) {
|
|
253
|
+
throw new FusionError('random slot shuffle failed', {
|
|
254
|
+
code: 'orchestration_failed',
|
|
255
|
+
childCreated: false,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
slots[i] = right;
|
|
259
|
+
slots[j] = left;
|
|
260
|
+
}
|
|
261
|
+
return slots;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function candidateBySlot(
|
|
265
|
+
results: readonly CandidateResult[],
|
|
266
|
+
slot: CandidateSlot,
|
|
267
|
+
): FusionChildRunResult {
|
|
268
|
+
const found = results.find((candidate) => candidate.slot === slot);
|
|
269
|
+
if (found === undefined) {
|
|
270
|
+
throw new FusionError(`candidate slot ${String(slot)} is missing`, {
|
|
271
|
+
code: 'orchestration_failed',
|
|
272
|
+
childCreated: false,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return found.result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function candidateModel(models: ResolvedFusionModels, slot: CandidateSlot): ResolvedFusionModel {
|
|
279
|
+
if (slot === 1) return models.candidates[0];
|
|
280
|
+
if (slot === 2) return models.candidates[1];
|
|
281
|
+
return models.candidates[2];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function anonymousCandidates(
|
|
285
|
+
results: readonly CandidateResult[],
|
|
286
|
+
slots: readonly CandidateSlot[],
|
|
287
|
+
): {
|
|
288
|
+
map: Record<FusionCandidateId, CandidateSlot>;
|
|
289
|
+
candidates: readonly [
|
|
290
|
+
AnonymousFusionCandidate,
|
|
291
|
+
AnonymousFusionCandidate,
|
|
292
|
+
AnonymousFusionCandidate,
|
|
293
|
+
];
|
|
294
|
+
} {
|
|
295
|
+
const firstSlot = slots[0];
|
|
296
|
+
const secondSlot = slots[1];
|
|
297
|
+
const thirdSlot = slots[2];
|
|
298
|
+
if (firstSlot === undefined || secondSlot === undefined || thirdSlot === undefined) {
|
|
299
|
+
throw new FusionError('anonymous candidate shuffle produced too few slots', {
|
|
300
|
+
code: 'orchestration_failed',
|
|
301
|
+
childCreated: false,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const first = candidateBySlot(results, firstSlot);
|
|
305
|
+
const second = candidateBySlot(results, secondSlot);
|
|
306
|
+
const third = candidateBySlot(results, thirdSlot);
|
|
307
|
+
return {
|
|
308
|
+
map: { A: firstSlot, B: secondSlot, C: thirdSlot },
|
|
309
|
+
candidates: [
|
|
310
|
+
{ candidate_id: 'A', response: first.text },
|
|
311
|
+
{ candidate_id: 'B', response: second.text },
|
|
312
|
+
{ candidate_id: 'C', response: third.text },
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export class FusionOrchestrator {
|
|
318
|
+
private readonly childRunner: FusionChildRunner;
|
|
319
|
+
private readonly randomBytes: FusionRandomBytes;
|
|
320
|
+
private readonly now: (() => Date) | undefined;
|
|
321
|
+
private readonly createArtifactStore: (
|
|
322
|
+
options: CreateFusionArtifactStoreOptions,
|
|
323
|
+
) => Promise<FusionArtifactStore>;
|
|
324
|
+
|
|
325
|
+
constructor(options: FusionOrchestratorOptions = {}) {
|
|
326
|
+
this.childRunner = options.childRunner ?? runPiChild;
|
|
327
|
+
this.randomBytes = options.randomBytes ?? nodeRandomBytes;
|
|
328
|
+
this.now = options.now;
|
|
329
|
+
this.createArtifactStore = options.createArtifactStore ?? FusionArtifactStore.create;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async run(input: FusionWorkflowInput): Promise<FusionRunResult> {
|
|
333
|
+
const storeOptions: CreateFusionArtifactStoreOptions = {
|
|
334
|
+
cwd: input.cwd,
|
|
335
|
+
source: input.source,
|
|
336
|
+
config: input.config,
|
|
337
|
+
models: input.models,
|
|
338
|
+
};
|
|
339
|
+
if (input.sessionId !== undefined) storeOptions.sessionId = input.sessionId;
|
|
340
|
+
if (this.now !== undefined) storeOptions.now = this.now;
|
|
341
|
+
const store = await this.createArtifactStore(storeOptions);
|
|
342
|
+
input.onProgress?.({ type: 'state', state: 'initializing' });
|
|
343
|
+
const usage = emptyUsage();
|
|
344
|
+
try {
|
|
345
|
+
await store.writeCanonicalInput(input.canonicalInputSerialized);
|
|
346
|
+
await store.transition('candidates_running');
|
|
347
|
+
input.onProgress?.({ type: 'state', state: 'candidates_running' });
|
|
348
|
+
const candidateResults = await this.runCandidates(input, store, usage);
|
|
349
|
+
await store.transition('candidates_complete');
|
|
350
|
+
input.onProgress?.({ type: 'state', state: 'candidates_complete' });
|
|
351
|
+
|
|
352
|
+
const shuffled = anonymousCandidates(candidateResults, shuffledSlots(this.randomBytes));
|
|
353
|
+
await store.setAnonymousMap(shuffled.map);
|
|
354
|
+
const blindInput = buildBlindEvaluationInput(input.canonicalInput, shuffled.candidates);
|
|
355
|
+
await store.writeBlindCandidates(buildEvaluationPrompt(blindInput));
|
|
356
|
+
|
|
357
|
+
await store.transition('evaluating');
|
|
358
|
+
input.onProgress?.({ type: 'state', state: 'evaluating' });
|
|
359
|
+
const evaluation = await this.runEvaluation(input, store, usage, blindInput);
|
|
360
|
+
await store.writeEvaluationJson(evaluation);
|
|
361
|
+
await store.transition('evaluation_complete');
|
|
362
|
+
input.onProgress?.({ type: 'state', state: 'evaluation_complete' });
|
|
363
|
+
|
|
364
|
+
await store.transition('merging');
|
|
365
|
+
input.onProgress?.({ type: 'state', state: 'merging' });
|
|
366
|
+
const mergeInput = buildMergeInput(input.canonicalInput, shuffled.candidates, evaluation);
|
|
367
|
+
const mergePrompt = buildMergePrompt(mergeInput);
|
|
368
|
+
input.onProgress?.({ type: 'merge_started' });
|
|
369
|
+
const merged = await this.runChildWithRetry(
|
|
370
|
+
input,
|
|
371
|
+
store,
|
|
372
|
+
usage,
|
|
373
|
+
input.models.merger,
|
|
374
|
+
'merge',
|
|
375
|
+
FUSION_MERGER_SYSTEM_PROMPT,
|
|
376
|
+
mergePrompt,
|
|
377
|
+
input.signal ?? new AbortController().signal,
|
|
378
|
+
undefined,
|
|
379
|
+
'md',
|
|
380
|
+
);
|
|
381
|
+
addUsage(usage, merged.usage);
|
|
382
|
+
await store.recordChildAttempt({ result: merged, prompt: mergePrompt, responseKind: 'md' });
|
|
383
|
+
await store.writeMerged(merged.text);
|
|
384
|
+
await store.setUsage(usage);
|
|
385
|
+
await store.transition('completed');
|
|
386
|
+
input.onProgress?.({ type: 'completed', runId: store.runId, artifactDir: store.artifactDir });
|
|
387
|
+
return {
|
|
388
|
+
mergedText: merged.text,
|
|
389
|
+
details: {
|
|
390
|
+
schema_version: FUSION_RESULT_SCHEMA_VERSION,
|
|
391
|
+
run_id: store.runId,
|
|
392
|
+
source: input.source,
|
|
393
|
+
status: 'completed',
|
|
394
|
+
artifact_dir: store.artifactDir,
|
|
395
|
+
models: store.snapshot().models,
|
|
396
|
+
evaluator_attempts: store
|
|
397
|
+
.snapshot()
|
|
398
|
+
.attempts.filter((attempt) => attempt.stage === 'evaluation').length,
|
|
399
|
+
usage,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const cancelled =
|
|
404
|
+
input.signal?.aborted === true ||
|
|
405
|
+
(error instanceof FusionError && error.code === 'child_cancelled');
|
|
406
|
+
const message = errorText(error);
|
|
407
|
+
try {
|
|
408
|
+
await store.setUsage(usage);
|
|
409
|
+
await store.writeError(cancelled ? 'cancelled' : 'failed', message);
|
|
410
|
+
} catch (artifactError) {
|
|
411
|
+
throw withTerminalArtifactFailure(error, store.artifactDir, artifactError);
|
|
412
|
+
}
|
|
413
|
+
if (cancelled) {
|
|
414
|
+
input.onProgress?.({
|
|
415
|
+
type: 'cancelled',
|
|
416
|
+
runId: store.runId,
|
|
417
|
+
artifactDir: store.artifactDir,
|
|
418
|
+
reason: message,
|
|
419
|
+
});
|
|
420
|
+
} else {
|
|
421
|
+
input.onProgress?.({
|
|
422
|
+
type: 'failed',
|
|
423
|
+
runId: store.runId,
|
|
424
|
+
artifactDir: store.artifactDir,
|
|
425
|
+
error: message,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
throw asFusionError(error, store.artifactDir);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async runCandidates(
|
|
433
|
+
input: FusionWorkflowInput,
|
|
434
|
+
store: FusionArtifactStore,
|
|
435
|
+
usage: FusionUsage,
|
|
436
|
+
): Promise<readonly CandidateResult[]> {
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const abortListener = () => controller.abort();
|
|
439
|
+
input.signal?.addEventListener('abort', abortListener, { once: true });
|
|
440
|
+
if (input.signal?.aborted) controller.abort();
|
|
441
|
+
const prompt = buildCandidatePrompt(input.canonicalInput);
|
|
442
|
+
let primaryError: unknown;
|
|
443
|
+
let completed = 0;
|
|
444
|
+
try {
|
|
445
|
+
if (controller.signal.aborted) {
|
|
446
|
+
throw new FusionError('fusion candidate wave cancelled before launch', {
|
|
447
|
+
code: 'child_cancelled',
|
|
448
|
+
stage: 'candidate',
|
|
449
|
+
childCreated: false,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
const tasks: Array<Promise<CandidateResult>> = ([1, 2, 3] as const).map((slot) => {
|
|
453
|
+
const model = candidateModel(input.models, slot);
|
|
454
|
+
const task = this.runChildWithRetry(
|
|
455
|
+
input,
|
|
456
|
+
store,
|
|
457
|
+
usage,
|
|
458
|
+
model,
|
|
459
|
+
'candidate',
|
|
460
|
+
FUSION_CANDIDATE_SYSTEM_PROMPT,
|
|
461
|
+
prompt,
|
|
462
|
+
controller.signal,
|
|
463
|
+
slot,
|
|
464
|
+
'md',
|
|
465
|
+
).then(async (result) => {
|
|
466
|
+
await store.recordChildAttempt({ result, prompt, responseKind: 'md' });
|
|
467
|
+
completed += 1;
|
|
468
|
+
addUsage(usage, result.usage);
|
|
469
|
+
await store.setUsage(usage);
|
|
470
|
+
input.onProgress?.({ type: 'candidate_completed', slot, completed, total: 3 });
|
|
471
|
+
return { slot, result };
|
|
472
|
+
});
|
|
473
|
+
return task.catch((error: unknown) => {
|
|
474
|
+
if (primaryError === undefined) {
|
|
475
|
+
primaryError = error;
|
|
476
|
+
controller.abort();
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
const settled = await Promise.allSettled(tasks);
|
|
482
|
+
if (primaryError !== undefined) throw primaryError;
|
|
483
|
+
const results: CandidateResult[] = [];
|
|
484
|
+
for (const item of settled) {
|
|
485
|
+
if (item.status === 'fulfilled') results.push(item.value);
|
|
486
|
+
else throw item.reason;
|
|
487
|
+
}
|
|
488
|
+
return results.sort((left, right) => left.slot - right.slot);
|
|
489
|
+
} finally {
|
|
490
|
+
input.signal?.removeEventListener('abort', abortListener);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async runEvaluation(
|
|
495
|
+
input: FusionWorkflowInput,
|
|
496
|
+
store: FusionArtifactStore,
|
|
497
|
+
usage: FusionUsage,
|
|
498
|
+
blindInput: Parameters<typeof buildEvaluationPrompt>[0],
|
|
499
|
+
): Promise<FusionEvaluationV1> {
|
|
500
|
+
const firstPrompt = buildEvaluationPrompt(blindInput);
|
|
501
|
+
const first = await this.runEvaluationAttempt(input, store, usage, firstPrompt, 1, false);
|
|
502
|
+
if (first.evaluation !== undefined) return first.evaluation;
|
|
503
|
+
const errors = boundedEvaluationErrors(first.errors);
|
|
504
|
+
input.onProgress?.({ type: 'evaluation_retry', errors });
|
|
505
|
+
const repairPrompt = buildEvaluationRepairPrompt({
|
|
506
|
+
schema_version: 'pi-background-tasks.fusion-evaluation-repair-input.v1',
|
|
507
|
+
original_blind_input: blindInput,
|
|
508
|
+
invalid_output: first.result.text,
|
|
509
|
+
validation_errors: errors,
|
|
510
|
+
});
|
|
511
|
+
const second = await this.runEvaluationAttempt(input, store, usage, repairPrompt, 2, true);
|
|
512
|
+
if (second.evaluation !== undefined) return second.evaluation;
|
|
513
|
+
throw new FusionError(
|
|
514
|
+
`evaluation schema repair failed: ${formatEvaluationErrors(second.errors)}`,
|
|
515
|
+
{
|
|
516
|
+
code: 'evaluation_invalid',
|
|
517
|
+
stage: 'evaluation',
|
|
518
|
+
attempt: 2,
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async runEvaluationAttempt(
|
|
524
|
+
input: FusionWorkflowInput,
|
|
525
|
+
store: FusionArtifactStore,
|
|
526
|
+
usage: FusionUsage,
|
|
527
|
+
prompt: string,
|
|
528
|
+
attempt: 1 | 2,
|
|
529
|
+
repair: boolean,
|
|
530
|
+
): Promise<EvaluationAttemptResult> {
|
|
531
|
+
input.onProgress?.({ type: 'evaluation_started', attempt, repair });
|
|
532
|
+
const systemPrompt = repair
|
|
533
|
+
? FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT
|
|
534
|
+
: FUSION_EVALUATOR_SYSTEM_PROMPT;
|
|
535
|
+
const result = await this.runChildWithRetry(
|
|
536
|
+
input,
|
|
537
|
+
store,
|
|
538
|
+
usage,
|
|
539
|
+
input.models.evaluator,
|
|
540
|
+
'evaluation',
|
|
541
|
+
systemPrompt,
|
|
542
|
+
prompt,
|
|
543
|
+
input.signal ?? new AbortController().signal,
|
|
544
|
+
undefined,
|
|
545
|
+
'txt',
|
|
546
|
+
attempt,
|
|
547
|
+
);
|
|
548
|
+
addUsage(usage, result.usage);
|
|
549
|
+
await store.recordChildAttempt({ result, prompt, responseKind: 'txt' });
|
|
550
|
+
await store.setUsage(usage);
|
|
551
|
+
const parsed = parseEvaluationAttempt(result.text);
|
|
552
|
+
return { result, evaluation: parsed.evaluation, errors: parsed.errors };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async runChildWithRetry(
|
|
556
|
+
input: FusionWorkflowInput,
|
|
557
|
+
store: FusionArtifactStore,
|
|
558
|
+
usage: FusionUsage,
|
|
559
|
+
model: ResolvedFusionModel,
|
|
560
|
+
stage: FusionStage,
|
|
561
|
+
systemPrompt: string,
|
|
562
|
+
userPrompt: string,
|
|
563
|
+
signal: AbortSignal,
|
|
564
|
+
slot: CandidateSlot | undefined,
|
|
565
|
+
responseKind: 'md' | 'txt',
|
|
566
|
+
fixedAttempt?: 1 | 2,
|
|
567
|
+
): Promise<FusionChildRunResult> {
|
|
568
|
+
const logicalAttempt = fixedAttempt ?? 1;
|
|
569
|
+
for (let launchTry = 1; launchTry <= 2; launchTry++) {
|
|
570
|
+
if (stage === 'candidate' && slot !== undefined) {
|
|
571
|
+
input.onProgress?.({ type: 'candidate_started', slot, attempt: logicalAttempt });
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
return await this.childRunner(
|
|
575
|
+
childOptions(input, model, stage, logicalAttempt, systemPrompt, userPrompt, signal, slot),
|
|
576
|
+
);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;
|
|
579
|
+
addFailedChildUsage(usage, error);
|
|
580
|
+
await store.recordFailedAttempt(
|
|
581
|
+
recordFailureInput(error, stage, slot, logicalAttempt, userPrompt, responseKind),
|
|
582
|
+
);
|
|
583
|
+
await store.setUsage(usage);
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const details: FusionErrorDetails = {
|
|
588
|
+
code: 'orchestration_failed',
|
|
589
|
+
stage,
|
|
590
|
+
childCreated: false,
|
|
591
|
+
};
|
|
592
|
+
if (slot !== undefined) details.slot = slot;
|
|
593
|
+
throw new FusionError(`${stage} child did not produce a result`, details);
|
|
594
|
+
}
|
|
595
|
+
}
|