pi-background-tasks 0.7.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISHING.md +7 -7
- package/README.md +13 -9
- package/TESTING.md +5 -5
- package/TEST_PLAN.md +9 -9
- package/extensions/fusion-child.ts +1 -0
- package/package.json +1 -1
- package/src/core/common.ts +60 -0
- package/src/core/fusion/artifacts.ts +18 -20
- package/src/core/fusion/orchestrator.ts +11 -20
- package/src/core/fusion/pi-child.ts +296 -208
- package/src/core/fusion/types.ts +51 -11
- package/src/core/registry.ts +1 -0
- package/src/extension.ts +31 -14
- package/src/fusion-child-extension.ts +91 -0
- package/src/fusion-extension.ts +34 -17
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
FUSION_CHILD_RESULT_PREFIX,
|
|
8
|
+
FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
9
|
+
type FusionChildResultMetadata,
|
|
10
|
+
} from '../../fusion-child-extension.js';
|
|
4
11
|
import {
|
|
5
12
|
FusionError,
|
|
13
|
+
addFusionUsage,
|
|
14
|
+
cloneFusionUsage,
|
|
15
|
+
createEmptyFusionUsage,
|
|
6
16
|
type FusionChildRunResult,
|
|
7
17
|
type FusionErrorDetails,
|
|
8
18
|
type FusionStage,
|
|
9
19
|
type FusionUsage,
|
|
20
|
+
type ResolvedFusionModel,
|
|
10
21
|
} from './types.js';
|
|
11
22
|
import { isJsonObject, parseJsonText } from '../common.js';
|
|
12
23
|
|
|
24
|
+
// The response cap now applies to one final full answer, not cumulative Pi JSON events.
|
|
13
25
|
export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
14
26
|
export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
15
27
|
export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -76,6 +88,7 @@ export interface RunPiChildOptions {
|
|
|
76
88
|
platform?: NodeJS.Platform | undefined;
|
|
77
89
|
env?: NodeJS.ProcessEnv | undefined;
|
|
78
90
|
stdoutLimitBytes?: number | undefined;
|
|
91
|
+
childExtensionPath?: string | undefined;
|
|
79
92
|
stderrLimitBytes?: number | undefined;
|
|
80
93
|
timeoutMs?: number | undefined;
|
|
81
94
|
killGraceMs?: number | undefined;
|
|
@@ -105,7 +118,8 @@ interface ObservedChildSnapshot {
|
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
export class FusionChildRunError extends FusionError {
|
|
108
|
-
readonly
|
|
121
|
+
readonly events: Buffer;
|
|
122
|
+
readonly response: Buffer;
|
|
109
123
|
readonly stderr: Buffer;
|
|
110
124
|
readonly exitCode: number | null;
|
|
111
125
|
readonly signalName: NodeJS.Signals | null;
|
|
@@ -116,7 +130,8 @@ export class FusionChildRunError extends FusionError {
|
|
|
116
130
|
|
|
117
131
|
constructor(
|
|
118
132
|
error: FusionError,
|
|
119
|
-
|
|
133
|
+
events: Buffer,
|
|
134
|
+
response: Buffer,
|
|
120
135
|
stderr: Buffer,
|
|
121
136
|
close: CloseRecord,
|
|
122
137
|
observed: ObservedChildSnapshot,
|
|
@@ -132,11 +147,12 @@ export class FusionChildRunError extends FusionError {
|
|
|
132
147
|
if (error.artifactDir !== undefined) details.artifactDir = error.artifactDir;
|
|
133
148
|
super(error.message, details);
|
|
134
149
|
this.name = 'FusionChildRunError';
|
|
135
|
-
this.
|
|
150
|
+
this.events = events;
|
|
151
|
+
this.response = response;
|
|
136
152
|
this.stderr = stderr;
|
|
137
153
|
this.exitCode = close.code;
|
|
138
154
|
this.signalName = close.signal;
|
|
139
|
-
this.usage =
|
|
155
|
+
this.usage = cloneFusionUsage(observed.usage);
|
|
140
156
|
this.provider = observed.provider;
|
|
141
157
|
this.modelName = observed.model;
|
|
142
158
|
this.qualifiedId = observed.qualifiedId;
|
|
@@ -150,10 +166,27 @@ export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.P
|
|
|
150
166
|
return out;
|
|
151
167
|
}
|
|
152
168
|
|
|
153
|
-
export function
|
|
169
|
+
export function resolveFusionChildExtensionPath(
|
|
170
|
+
moduleUrl = import.meta.url,
|
|
171
|
+
pathExists: (path: string) => boolean = existsSync,
|
|
172
|
+
): string {
|
|
173
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
174
|
+
const extension = modulePath.endsWith('.ts') ? 'fusion-child.ts' : 'fusion-child.js';
|
|
175
|
+
const candidate = resolve(dirname(modulePath), '../../../extensions', extension);
|
|
176
|
+
if (!pathExists(candidate)) {
|
|
177
|
+
throw new Error(`Fusion child metadata extension is missing: ${candidate}`);
|
|
178
|
+
}
|
|
179
|
+
return candidate;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function buildFusionPiChildArgv(
|
|
183
|
+
model: ResolvedFusionModel,
|
|
184
|
+
systemPrompt: string,
|
|
185
|
+
childExtensionPath = resolveFusionChildExtensionPath(),
|
|
186
|
+
): string[] {
|
|
154
187
|
return [
|
|
155
188
|
'--mode',
|
|
156
|
-
'
|
|
189
|
+
'text',
|
|
157
190
|
'--no-session',
|
|
158
191
|
'--no-tools',
|
|
159
192
|
'--no-extensions',
|
|
@@ -161,6 +194,8 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
|
|
|
161
194
|
'--no-prompt-templates',
|
|
162
195
|
'--no-themes',
|
|
163
196
|
'--no-context-files',
|
|
197
|
+
'--extension',
|
|
198
|
+
childExtensionPath,
|
|
164
199
|
'--provider',
|
|
165
200
|
model.provider,
|
|
166
201
|
'--model',
|
|
@@ -172,207 +207,263 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
|
|
|
172
207
|
];
|
|
173
208
|
}
|
|
174
209
|
|
|
175
|
-
|
|
176
|
-
|
|
210
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
211
|
+
const FUSION_CHILD_RESULT_PREFIX_BYTES = Buffer.from(FUSION_CHILD_RESULT_PREFIX, 'utf8');
|
|
212
|
+
|
|
213
|
+
interface ParsedFusionChildStderr {
|
|
214
|
+
records: FusionChildResultMetadata[];
|
|
215
|
+
events: Buffer;
|
|
216
|
+
diagnostics: Buffer;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function assertClosedRecord(
|
|
220
|
+
value: unknown,
|
|
221
|
+
keys: readonly string[],
|
|
222
|
+
label: string,
|
|
223
|
+
): Record<PropertyKey, unknown> {
|
|
224
|
+
if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
225
|
+
const actual = Object.keys(value).sort();
|
|
226
|
+
const expected = [...keys].sort();
|
|
227
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
228
|
+
throw new Error(`${label} keys mismatch: expected ${expected.join(', ')}`);
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function requireNonBlankString(
|
|
234
|
+
record: Record<PropertyKey, unknown>,
|
|
235
|
+
key: string,
|
|
236
|
+
label: string,
|
|
237
|
+
): string {
|
|
238
|
+
const value = record[key];
|
|
239
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
240
|
+
throw new Error(`${label}.${key} must be a non-blank string`);
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function requireSha256(record: Record<PropertyKey, unknown>, key: string, label: string): string {
|
|
245
|
+
const value = record[key];
|
|
246
|
+
if (typeof value !== 'string' || !SHA256_HEX_PATTERN.test(value))
|
|
247
|
+
throw new Error(`${label}.${key} must be a lowercase SHA-256 hex digest`);
|
|
248
|
+
return value;
|
|
177
249
|
}
|
|
178
250
|
|
|
179
|
-
function
|
|
251
|
+
function requireUsageInteger(
|
|
252
|
+
record: Record<PropertyKey, unknown>,
|
|
253
|
+
key: string,
|
|
254
|
+
label: string,
|
|
255
|
+
): number {
|
|
180
256
|
const value = record[key];
|
|
181
|
-
|
|
257
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
258
|
+
throw new Error(`${label}.${key} must be a non-negative safe integer`);
|
|
259
|
+
return value;
|
|
182
260
|
}
|
|
183
261
|
|
|
184
|
-
function
|
|
262
|
+
function requireCostNumber(
|
|
185
263
|
record: Record<PropertyKey, unknown>,
|
|
186
264
|
key: string,
|
|
187
|
-
|
|
265
|
+
label: string,
|
|
266
|
+
): number {
|
|
188
267
|
const value = record[key];
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
268
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
269
|
+
throw new Error(`${label}.${key} must be a non-negative finite number`);
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseCompactUsage(value: unknown): FusionUsage {
|
|
274
|
+
const record = assertClosedRecord(
|
|
275
|
+
value,
|
|
276
|
+
['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'cost'],
|
|
277
|
+
'fusion child usage',
|
|
278
|
+
);
|
|
279
|
+
const cost = assertClosedRecord(
|
|
280
|
+
record['cost'],
|
|
281
|
+
['input', 'output', 'cacheRead', 'cacheWrite', 'total'],
|
|
282
|
+
'fusion child usage.cost',
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
input: requireUsageInteger(record, 'input', 'fusion child usage'),
|
|
286
|
+
output: requireUsageInteger(record, 'output', 'fusion child usage'),
|
|
287
|
+
cacheRead: requireUsageInteger(record, 'cacheRead', 'fusion child usage'),
|
|
288
|
+
cacheWrite: requireUsageInteger(record, 'cacheWrite', 'fusion child usage'),
|
|
289
|
+
totalTokens: requireUsageInteger(record, 'totalTokens', 'fusion child usage'),
|
|
290
|
+
cost: {
|
|
291
|
+
input: requireCostNumber(cost, 'input', 'fusion child usage.cost'),
|
|
292
|
+
output: requireCostNumber(cost, 'output', 'fusion child usage.cost'),
|
|
293
|
+
cacheRead: requireCostNumber(cost, 'cacheRead', 'fusion child usage.cost'),
|
|
294
|
+
cacheWrite: requireCostNumber(cost, 'cacheWrite', 'fusion child usage.cost'),
|
|
295
|
+
total: requireCostNumber(cost, 'total', 'fusion child usage.cost'),
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseChildResultMetadata(value: unknown): FusionChildResultMetadata {
|
|
301
|
+
const record = assertClosedRecord(
|
|
302
|
+
value,
|
|
303
|
+
['schema_version', 'provider', 'model', 'stop_reason', 'text_blocks', 'text_sha256', 'usage'],
|
|
304
|
+
'fusion child result',
|
|
305
|
+
);
|
|
306
|
+
if (record['schema_version'] !== FUSION_CHILD_RESULT_SCHEMA_VERSION)
|
|
307
|
+
throw new Error('fusion child result schema_version mismatch');
|
|
308
|
+
const textBlocksValue = record['text_blocks'];
|
|
309
|
+
if (!Array.isArray(textBlocksValue))
|
|
310
|
+
throw new Error('fusion child result.text_blocks must be an array');
|
|
311
|
+
const textBlocks = textBlocksValue.map((value, index) => {
|
|
312
|
+
const label = `fusion child result.text_blocks[${String(index)}]`;
|
|
313
|
+
const block = assertClosedRecord(value, ['utf8_bytes', 'sha256'], label);
|
|
314
|
+
return {
|
|
315
|
+
utf8_bytes: requireUsageInteger(block, 'utf8_bytes', label),
|
|
316
|
+
sha256: requireSha256(block, 'sha256', label),
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
const usage = parseCompactUsage(record['usage']);
|
|
320
|
+
return {
|
|
321
|
+
schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
322
|
+
provider: requireNonBlankString(record, 'provider', 'fusion child result'),
|
|
323
|
+
model: requireNonBlankString(record, 'model', 'fusion child result'),
|
|
324
|
+
stop_reason: requireNonBlankString(record, 'stop_reason', 'fusion child result'),
|
|
325
|
+
text_blocks: textBlocks,
|
|
326
|
+
text_sha256: requireSha256(record, 'text_sha256', 'fusion child result'),
|
|
327
|
+
usage,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr {
|
|
332
|
+
const records: FusionChildResultMetadata[] = [];
|
|
333
|
+
const diagnostics: Buffer[] = [];
|
|
334
|
+
let cursor = 0;
|
|
335
|
+
for (;;) {
|
|
336
|
+
const frameStart = stderr.indexOf(FUSION_CHILD_RESULT_PREFIX_BYTES, cursor);
|
|
337
|
+
if (frameStart < 0) {
|
|
338
|
+
if (cursor < stderr.length) diagnostics.push(stderr.subarray(cursor));
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
if (frameStart > cursor) diagnostics.push(stderr.subarray(cursor, frameStart));
|
|
342
|
+
const payloadStart = frameStart + FUSION_CHILD_RESULT_PREFIX_BYTES.length;
|
|
343
|
+
const newline = stderr.indexOf(10, payloadStart);
|
|
344
|
+
if (newline < 0) throw new Error('fusion child metadata frame is not newline-terminated');
|
|
345
|
+
const payloadBytes = stderr.subarray(payloadStart, newline);
|
|
346
|
+
const payloadText = payloadBytes.toString('utf8');
|
|
347
|
+
if (!Buffer.from(payloadText, 'utf8').equals(payloadBytes))
|
|
348
|
+
throw new Error('fusion child metadata frame is not valid UTF-8');
|
|
349
|
+
let parsed: unknown;
|
|
350
|
+
try {
|
|
351
|
+
parsed = parseJsonText(payloadText);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`fusion child metadata frame is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
records.push(parseChildResultMetadata(parsed));
|
|
358
|
+
cursor = newline + 1;
|
|
202
359
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
360
|
+
const events = Buffer.from(
|
|
361
|
+
records.length === 0 ? '' : `${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
|
362
|
+
'utf8',
|
|
363
|
+
);
|
|
364
|
+
return { records, events, diagnostics: Buffer.concat(diagnostics) };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function sha256Buffer(bytes: Buffer): string {
|
|
368
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function reconstructFinalText(response: Buffer, record: FusionChildResultMetadata): string {
|
|
372
|
+
const blocks: Buffer[] = [];
|
|
373
|
+
let cursor = 0;
|
|
374
|
+
for (const [index, block] of record.text_blocks.entries()) {
|
|
375
|
+
const end = cursor + block.utf8_bytes;
|
|
376
|
+
if (end > response.length)
|
|
377
|
+
throw new Error(`Pi final text block ${String(index)} is shorter than its metadata length`);
|
|
378
|
+
const bytes = response.subarray(cursor, end);
|
|
379
|
+
if (sha256Buffer(bytes) !== block.sha256)
|
|
380
|
+
throw new Error(`Pi final text block ${String(index)} hash mismatch`);
|
|
381
|
+
blocks.push(bytes);
|
|
382
|
+
if (response.at(end) !== 10)
|
|
383
|
+
throw new Error(`Pi final text block ${String(index)} lacks its print-mode newline`);
|
|
384
|
+
cursor = end + 1;
|
|
225
385
|
}
|
|
226
|
-
|
|
386
|
+
if (cursor !== response.length)
|
|
387
|
+
throw new Error('Pi final text stdout contains bytes outside declared text blocks');
|
|
388
|
+
const joined = Buffer.concat(blocks);
|
|
389
|
+
if (sha256Buffer(joined) !== record.text_sha256)
|
|
390
|
+
throw new Error('Pi final text aggregate hash mismatch');
|
|
391
|
+
const text = joined.toString('utf8');
|
|
392
|
+
if (!Buffer.from(text, 'utf8').equals(joined))
|
|
393
|
+
throw new Error('Pi final text is not valid UTF-8');
|
|
394
|
+
if (text.trim().length === 0) throw new Error('Pi assistant response is empty');
|
|
395
|
+
return text;
|
|
227
396
|
}
|
|
228
397
|
|
|
229
|
-
export class
|
|
230
|
-
private readonly decoder = new StringDecoder('utf8');
|
|
398
|
+
export class FusionPiCompactResultParser {
|
|
231
399
|
private readonly expectedProvider: string;
|
|
232
400
|
private readonly expectedModel: string;
|
|
233
|
-
private lineBuffer = '';
|
|
234
|
-
private bytesSeen = 0;
|
|
235
|
-
private lastByteWasLf = false;
|
|
236
|
-
private sessionCount = 0;
|
|
237
|
-
private sessionId: string | undefined;
|
|
238
|
-
private sessionCwd: string | undefined;
|
|
239
|
-
private assistantCount = 0;
|
|
240
|
-
private finalProvider: string | undefined;
|
|
241
|
-
private finalModel: string | undefined;
|
|
242
|
-
private finalStopReason: string | undefined;
|
|
243
|
-
private finalText = '';
|
|
244
|
-
private readonly usage: FusionUsage = {
|
|
245
|
-
input: 0,
|
|
246
|
-
output: 0,
|
|
247
|
-
cacheRead: 0,
|
|
248
|
-
cacheWrite: 0,
|
|
249
|
-
totalTokens: 0,
|
|
250
|
-
};
|
|
251
401
|
|
|
252
402
|
constructor(expectedProvider: string, expectedModel: string) {
|
|
253
403
|
this.expectedProvider = expectedProvider;
|
|
254
404
|
this.expectedModel = expectedModel;
|
|
255
405
|
}
|
|
256
406
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
snapshot(): ObservedChildSnapshot {
|
|
266
|
-
const observed: ObservedChildSnapshot = { usage: { ...this.usage } };
|
|
267
|
-
if (this.finalProvider !== undefined) observed.provider = this.finalProvider;
|
|
268
|
-
if (this.finalModel !== undefined) observed.model = this.finalModel;
|
|
269
|
-
if (this.finalProvider !== undefined && this.finalModel !== undefined) {
|
|
270
|
-
observed.qualifiedId = `${this.finalProvider}/${this.finalModel}`;
|
|
407
|
+
snapshot(stderr: Buffer): ObservedChildSnapshot {
|
|
408
|
+
try {
|
|
409
|
+
const parsed = parseFusionChildStderr(stderr);
|
|
410
|
+
return this.observedFromRecords(parsed.records);
|
|
411
|
+
} catch {
|
|
412
|
+
return { usage: createEmptyFusionUsage() };
|
|
271
413
|
}
|
|
272
|
-
return observed;
|
|
273
414
|
}
|
|
274
415
|
|
|
275
|
-
finish(
|
|
416
|
+
finish(
|
|
417
|
+
response: Buffer,
|
|
418
|
+
stderr: Buffer,
|
|
419
|
+
): {
|
|
276
420
|
text: string;
|
|
277
421
|
usage: FusionUsage;
|
|
278
422
|
provider: string;
|
|
279
423
|
model: string;
|
|
280
424
|
qualifiedId: string;
|
|
425
|
+
events: Buffer;
|
|
426
|
+
diagnostics: Buffer;
|
|
281
427
|
} {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
throw new Error(
|
|
288
|
-
|
|
289
|
-
throw new Error('Pi JSON events must contain exactly one session header');
|
|
290
|
-
}
|
|
291
|
-
if (this.assistantCount < 1) throw new Error('Pi JSON events contain no assistant message');
|
|
292
|
-
if (this.finalProvider !== this.expectedProvider || this.finalModel !== this.expectedModel) {
|
|
293
|
-
throw new Error(
|
|
294
|
-
`Pi final model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${this.finalProvider ?? 'missing'}/${this.finalModel ?? 'missing'}`,
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
if (this.finalStopReason !== 'stop') {
|
|
298
|
-
throw new Error(`Pi final stop reason is not stop: ${this.finalStopReason ?? 'missing'}`);
|
|
299
|
-
}
|
|
300
|
-
if (this.finalText.trim().length === 0) throw new Error('Pi assistant response is empty');
|
|
428
|
+
const parsed = parseFusionChildStderr(stderr);
|
|
429
|
+
const final = parsed.records.at(-1);
|
|
430
|
+
if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
|
|
431
|
+
for (const record of parsed.records) this.assertModel(record);
|
|
432
|
+
if (final.stop_reason !== 'stop')
|
|
433
|
+
throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
|
|
434
|
+
const observed = this.observedFromRecords(parsed.records);
|
|
301
435
|
return {
|
|
302
|
-
text:
|
|
303
|
-
usage:
|
|
304
|
-
provider:
|
|
305
|
-
model:
|
|
306
|
-
qualifiedId: `${
|
|
436
|
+
text: reconstructFinalText(response, final),
|
|
437
|
+
usage: observed.usage,
|
|
438
|
+
provider: final.provider,
|
|
439
|
+
model: final.model,
|
|
440
|
+
qualifiedId: `${final.provider}/${final.model}`,
|
|
441
|
+
events: parsed.events,
|
|
442
|
+
diagnostics: parsed.diagnostics,
|
|
307
443
|
};
|
|
308
444
|
}
|
|
309
445
|
|
|
310
|
-
private
|
|
311
|
-
|
|
312
|
-
while (newlineIndex >= 0) {
|
|
313
|
-
const raw = this.lineBuffer.slice(0, newlineIndex);
|
|
314
|
-
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
|
|
315
|
-
this.consumeLine(raw.endsWith('\r') ? raw.slice(0, -1) : raw);
|
|
316
|
-
newlineIndex = this.lineBuffer.indexOf('\n');
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
private consumeLine(line: string): void {
|
|
321
|
-
if (line.length === 0) throw new Error('Pi JSON event line is blank');
|
|
322
|
-
let parsed: unknown;
|
|
323
|
-
try {
|
|
324
|
-
parsed = parseJsonText(line);
|
|
325
|
-
} catch (error) {
|
|
446
|
+
private assertModel(record: FusionChildResultMetadata): void {
|
|
447
|
+
if (record.provider !== this.expectedProvider || record.model !== this.expectedModel) {
|
|
326
448
|
throw new Error(
|
|
327
|
-
`Pi
|
|
449
|
+
`Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${record.provider}/${record.model}`,
|
|
328
450
|
);
|
|
329
451
|
}
|
|
330
|
-
if (!isJsonObject(parsed) || Array.isArray(parsed))
|
|
331
|
-
throw new Error('Pi JSON event line is not an object');
|
|
332
|
-
const eventType = parsed['type'];
|
|
333
|
-
if (eventType === 'session') {
|
|
334
|
-
this.consumeSession(parsed);
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (eventType === 'message_end') this.consumeMessageEnd(parsed);
|
|
338
452
|
}
|
|
339
453
|
|
|
340
|
-
private
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (message['role'] !== 'assistant') return;
|
|
354
|
-
const provider = readString(message, 'provider');
|
|
355
|
-
const model = readString(message, 'model');
|
|
356
|
-
if (
|
|
357
|
-
provider === undefined ||
|
|
358
|
-
provider.trim().length === 0 ||
|
|
359
|
-
model === undefined ||
|
|
360
|
-
model.trim().length === 0
|
|
361
|
-
) {
|
|
362
|
-
throw new Error('Pi assistant message lacks provider/model');
|
|
363
|
-
}
|
|
364
|
-
if (provider !== this.expectedProvider || model !== this.expectedModel) {
|
|
365
|
-
throw new Error(
|
|
366
|
-
`Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${provider}/${model}`,
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
this.assistantCount += 1;
|
|
370
|
-
this.finalProvider = provider;
|
|
371
|
-
this.finalModel = model;
|
|
372
|
-
const stopReason = readString(message, 'stopReason');
|
|
373
|
-
this.finalStopReason = stopReason;
|
|
374
|
-
this.finalText = textBlocks(message).join('');
|
|
375
|
-
addUsage(this.usage, normalizeUsage(message['usage']));
|
|
454
|
+
private observedFromRecords(
|
|
455
|
+
records: readonly FusionChildResultMetadata[],
|
|
456
|
+
): ObservedChildSnapshot {
|
|
457
|
+
const usage = createEmptyFusionUsage();
|
|
458
|
+
for (const record of records) addFusionUsage(usage, record.usage);
|
|
459
|
+
const final = records.at(-1);
|
|
460
|
+
if (final === undefined) return { usage };
|
|
461
|
+
return {
|
|
462
|
+
usage,
|
|
463
|
+
provider: final.provider,
|
|
464
|
+
model: final.model,
|
|
465
|
+
qualifiedId: `${final.provider}/${final.model}`,
|
|
466
|
+
};
|
|
376
467
|
}
|
|
377
468
|
}
|
|
378
469
|
|
|
@@ -591,8 +682,12 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
591
682
|
const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
|
|
592
683
|
const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
|
|
593
684
|
const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
|
|
594
|
-
const argv = buildFusionPiChildArgv(
|
|
595
|
-
|
|
685
|
+
const argv = buildFusionPiChildArgv(
|
|
686
|
+
options.model,
|
|
687
|
+
options.systemPrompt,
|
|
688
|
+
options.childExtensionPath ?? resolveFusionChildExtensionPath(),
|
|
689
|
+
);
|
|
690
|
+
const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
|
|
596
691
|
const stdoutChunks: Buffer[] = [];
|
|
597
692
|
const stderrChunks: Buffer[] = [];
|
|
598
693
|
let stdoutBytes = 0;
|
|
@@ -648,29 +743,9 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
648
743
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
649
744
|
const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
|
|
650
745
|
stdoutBytes = appended.bytes;
|
|
651
|
-
if (state.primaryError === undefined && appended.accepted.length > 0) {
|
|
652
|
-
try {
|
|
653
|
-
parser.push(appended.accepted);
|
|
654
|
-
} catch (error) {
|
|
655
|
-
state.primaryError = childError(
|
|
656
|
-
`Pi child JSON event stream invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
657
|
-
'child_event_invalid',
|
|
658
|
-
options,
|
|
659
|
-
);
|
|
660
|
-
terminateChild(
|
|
661
|
-
child,
|
|
662
|
-
state,
|
|
663
|
-
platform,
|
|
664
|
-
killProcess,
|
|
665
|
-
killGraceMs,
|
|
666
|
-
sigkillWaitMs,
|
|
667
|
-
settleClose,
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
746
|
if (appended.exceeded && state.primaryError === undefined) {
|
|
672
747
|
state.primaryError = childError(
|
|
673
|
-
`Pi child
|
|
748
|
+
`Pi child final response exceeded ${String(stdoutLimit)} bytes`,
|
|
674
749
|
'child_output_cap',
|
|
675
750
|
options,
|
|
676
751
|
);
|
|
@@ -743,15 +818,26 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
743
818
|
}
|
|
744
819
|
|
|
745
820
|
const close = await closePromise;
|
|
746
|
-
const
|
|
747
|
-
const
|
|
748
|
-
const observed = parser.snapshot();
|
|
821
|
+
const response = Buffer.concat(stdoutChunks);
|
|
822
|
+
const rawStderr = Buffer.concat(stderrChunks);
|
|
823
|
+
const observed = parser.snapshot(rawStderr);
|
|
824
|
+
let compactEvents: Buffer = Buffer.alloc(0);
|
|
825
|
+
let diagnostics: Buffer = rawStderr;
|
|
826
|
+
try {
|
|
827
|
+
const decoded = parseFusionChildStderr(rawStderr);
|
|
828
|
+
compactEvents = decoded.events;
|
|
829
|
+
diagnostics = decoded.diagnostics;
|
|
830
|
+
} catch {
|
|
831
|
+
// A primary process/cap error remains authoritative; malformed metadata is
|
|
832
|
+
// surfaced below when the child otherwise exits successfully.
|
|
833
|
+
}
|
|
749
834
|
const primary = state.primaryError;
|
|
750
835
|
if (primary !== undefined)
|
|
751
836
|
throw new FusionChildRunError(
|
|
752
837
|
withCleanupErrors(primary, state.cleanupErrors),
|
|
753
|
-
|
|
754
|
-
|
|
838
|
+
compactEvents,
|
|
839
|
+
response,
|
|
840
|
+
diagnostics,
|
|
755
841
|
close,
|
|
756
842
|
observed,
|
|
757
843
|
);
|
|
@@ -765,27 +851,29 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
765
851
|
),
|
|
766
852
|
state.cleanupErrors,
|
|
767
853
|
),
|
|
768
|
-
|
|
769
|
-
|
|
854
|
+
compactEvents,
|
|
855
|
+
response,
|
|
856
|
+
diagnostics,
|
|
770
857
|
close,
|
|
771
858
|
observed,
|
|
772
859
|
);
|
|
773
860
|
}
|
|
774
|
-
let parsed: ReturnType<
|
|
861
|
+
let parsed: ReturnType<FusionPiCompactResultParser['finish']>;
|
|
775
862
|
try {
|
|
776
|
-
parsed = parser.finish();
|
|
863
|
+
parsed = parser.finish(response, rawStderr);
|
|
777
864
|
} catch (error) {
|
|
778
865
|
throw new FusionChildRunError(
|
|
779
866
|
withCleanupErrors(
|
|
780
867
|
childError(
|
|
781
|
-
`Pi child
|
|
868
|
+
`Pi child compact result invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
782
869
|
'child_event_invalid',
|
|
783
870
|
options,
|
|
784
871
|
),
|
|
785
872
|
state.cleanupErrors,
|
|
786
873
|
),
|
|
787
|
-
|
|
788
|
-
|
|
874
|
+
compactEvents,
|
|
875
|
+
response,
|
|
876
|
+
diagnostics,
|
|
789
877
|
close,
|
|
790
878
|
observed,
|
|
791
879
|
);
|
|
@@ -798,8 +886,8 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
798
886
|
qualifiedId: parsed.qualifiedId,
|
|
799
887
|
text: parsed.text,
|
|
800
888
|
usage: parsed.usage,
|
|
801
|
-
|
|
802
|
-
stderr,
|
|
889
|
+
events: parsed.events,
|
|
890
|
+
stderr: parsed.diagnostics,
|
|
803
891
|
exitCode: close.code,
|
|
804
892
|
signal: close.signal,
|
|
805
893
|
};
|