pi-background-tasks 0.7.7 → 0.9.0
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 +64 -7
- package/TEST_PLAN.md +8 -1
- package/package.json +7 -2
- package/src/core/delegate/launch.ts +1 -0
- package/src/core/fusion/artifacts.ts +49 -4
- package/src/core/fusion/budget.ts +21 -12
- package/src/core/fusion/context.ts +7 -2
- package/src/core/fusion/orchestrator.ts +70 -15
- package/src/core/fusion/pi-child.ts +473 -8
- package/src/core/fusion/prompts.ts +151 -3
- package/src/core/fusion/types.ts +84 -2
- package/src/core/fusion/web-fetch.ts +904 -0
- package/src/core/fusion/workflows.ts +130 -0
- package/src/fusion-child-extension.ts +279 -2
- package/src/fusion-extension.ts +182 -27
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
4
6
|
import { dirname, resolve } from 'node:path';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import {
|
|
7
9
|
FUSION_CHILD_RESULT_PREFIX,
|
|
8
10
|
FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
11
|
+
FUSION_RESEARCH_ENABLED_ENV,
|
|
12
|
+
FUSION_TOOL_CALL_LOG_PATH_ENV,
|
|
9
13
|
type FusionChildResultMetadata,
|
|
10
14
|
} from '../../fusion-child-extension.js';
|
|
11
15
|
import {
|
|
16
|
+
FUSION_DEFAULT_CAPABILITY,
|
|
17
|
+
FUSION_FORBIDDEN_TOOLS,
|
|
18
|
+
FUSION_INSPECT_TOOLS,
|
|
19
|
+
FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
20
|
+
FUSION_WEB_FETCH_TOOL_NAME,
|
|
12
21
|
FusionError,
|
|
13
22
|
addFusionUsage,
|
|
14
23
|
cloneFusionUsage,
|
|
15
24
|
createEmptyFusionUsage,
|
|
25
|
+
type FusionCapability,
|
|
16
26
|
type FusionChildRunResult,
|
|
17
27
|
type FusionErrorDetails,
|
|
18
28
|
type FusionStage,
|
|
29
|
+
type FusionToolCallLogRecord,
|
|
30
|
+
type FusionToolCallTrace,
|
|
19
31
|
type FusionUsage,
|
|
20
32
|
type ResolvedFusionModel,
|
|
21
33
|
} from './types.js';
|
|
@@ -31,6 +43,18 @@ import {
|
|
|
31
43
|
export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
32
44
|
export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
33
45
|
export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
46
|
+
/**
|
|
47
|
+
* Stale-action watchdog threshold.
|
|
48
|
+
*
|
|
49
|
+
* Activity is one stdout or stderr byte from the child. The child extension emits its
|
|
50
|
+
* compact metadata frame only at `message_end`, and Pi text mode writes stdout only for
|
|
51
|
+
* the final assistant message, so a single slow model turn is genuinely silent on both
|
|
52
|
+
* streams. The threshold must therefore exceed the longest plausible single turn, not the
|
|
53
|
+
* longest plausible tool call: a value tuned to tool latency would kill healthy children
|
|
54
|
+
* mid-reasoning. 900s stays well inside the 30-minute absolute cap while leaving a wide
|
|
55
|
+
* margin over observed turn latency.
|
|
56
|
+
*/
|
|
57
|
+
export const FUSION_CHILD_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
34
58
|
export const FUSION_CHILD_KILL_GRACE_MS = 3000;
|
|
35
59
|
export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
|
|
36
60
|
|
|
@@ -86,6 +110,7 @@ export interface RunPiChildOptions {
|
|
|
86
110
|
attempt: number;
|
|
87
111
|
cwd: string;
|
|
88
112
|
model: ResolvedFusionModel;
|
|
113
|
+
capability?: FusionCapability | undefined;
|
|
89
114
|
systemPrompt: string;
|
|
90
115
|
userPrompt: string;
|
|
91
116
|
signal?: AbortSignal | undefined;
|
|
@@ -97,9 +122,11 @@ export interface RunPiChildOptions {
|
|
|
97
122
|
childExtensionPath?: string | undefined;
|
|
98
123
|
stderrLimitBytes?: number | undefined;
|
|
99
124
|
timeoutMs?: number | undefined;
|
|
125
|
+
idleTimeoutMs?: number | undefined;
|
|
100
126
|
killGraceMs?: number | undefined;
|
|
101
127
|
sigkillWaitMs?: number | undefined;
|
|
102
128
|
piLaunchDependencies?: PiLaunchDependencies | undefined;
|
|
129
|
+
toolCallLogPath?: string | undefined;
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
interface CloseRecord {
|
|
@@ -114,6 +141,7 @@ interface ProcessState {
|
|
|
114
141
|
termTimer: NodeJS.Timeout | undefined;
|
|
115
142
|
waitTimer: NodeJS.Timeout | undefined;
|
|
116
143
|
timeoutTimer: NodeJS.Timeout | undefined;
|
|
144
|
+
idleTimer: NodeJS.Timeout | undefined;
|
|
117
145
|
settled: boolean;
|
|
118
146
|
}
|
|
119
147
|
|
|
@@ -186,23 +214,198 @@ export function resolveFusionChildExtensionPath(
|
|
|
186
214
|
return candidate;
|
|
187
215
|
}
|
|
188
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Provider whose children require the Anthropic system-prompt sanitizer.
|
|
219
|
+
*
|
|
220
|
+
* Pi's own system prompt contains documentation lines that Anthropic rejects, so a
|
|
221
|
+
* Claude child launched without the sanitizer fails at the provider rather than
|
|
222
|
+
* producing an answer. The parent session loads the sanitizer through ordinary
|
|
223
|
+
* extension discovery, but Fusion children run with `--no-extensions` for
|
|
224
|
+
* isolation and therefore inherit nothing; the sanitizer must be re-supplied
|
|
225
|
+
* explicitly per child.
|
|
226
|
+
*/
|
|
227
|
+
export const FUSION_SANITIZED_PROVIDER = 'anthropic';
|
|
228
|
+
export const FUSION_ANTHROPIC_SANITIZER_PACKAGE = '@ravshansbox/pi-anthropic-sps';
|
|
229
|
+
const FUSION_ANTHROPIC_SANITIZER_MANIFEST = `${FUSION_ANTHROPIC_SANITIZER_PACKAGE}/package.json`;
|
|
230
|
+
|
|
231
|
+
export interface FusionSanitizerDependencies {
|
|
232
|
+
resolvePackageJson?: ((specifier: string) => string) | undefined;
|
|
233
|
+
readManifest?: ((path: string) => string) | undefined;
|
|
234
|
+
pathExists?: ((path: string) => boolean) | undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function manifestExtensionEntry(manifestText: string, manifestPath: string): string {
|
|
238
|
+
let parsed: unknown;
|
|
239
|
+
try {
|
|
240
|
+
parsed = parseJsonText(manifestText);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw new FusionError(
|
|
243
|
+
`Anthropic sanitizer manifest ${manifestPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
244
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (!isJsonObject(parsed)) {
|
|
248
|
+
throw new FusionError(`Anthropic sanitizer manifest ${manifestPath} must be an object`, {
|
|
249
|
+
code: 'orchestration_failed',
|
|
250
|
+
childCreated: false,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const pi = parsed['pi'];
|
|
254
|
+
if (!isJsonObject(pi)) {
|
|
255
|
+
throw new FusionError(
|
|
256
|
+
`Anthropic sanitizer manifest ${manifestPath} has no "pi" section declaring its extension`,
|
|
257
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const extensions = pi['extensions'];
|
|
261
|
+
if (!Array.isArray(extensions) || extensions.length === 0) {
|
|
262
|
+
throw new FusionError(
|
|
263
|
+
`Anthropic sanitizer manifest ${manifestPath} declares no pi.extensions entries`,
|
|
264
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const [entry] = extensions;
|
|
268
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
269
|
+
throw new FusionError(
|
|
270
|
+
`Anthropic sanitizer manifest ${manifestPath} pi.extensions[0] must be a non-blank string`,
|
|
271
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return entry;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolve the sanitizer extension file shipped by the sanitizer package.
|
|
279
|
+
*
|
|
280
|
+
* The package intentionally publishes no `main`/`exports`, so the entry cannot be
|
|
281
|
+
* required directly; its manifest is resolved and the declared `pi.extensions[0]`
|
|
282
|
+
* path is joined against the package root. Every failure is loud: a Claude child
|
|
283
|
+
* launched without the sanitizer would fail at the provider with a far less
|
|
284
|
+
* actionable error, so silently omitting it is never correct.
|
|
285
|
+
*/
|
|
286
|
+
export function resolveAnthropicSanitizerExtensionPath(
|
|
287
|
+
dependencies: FusionSanitizerDependencies = {},
|
|
288
|
+
): string {
|
|
289
|
+
const resolvePackageJson =
|
|
290
|
+
dependencies.resolvePackageJson ?? createRequire(import.meta.url).resolve;
|
|
291
|
+
const readManifest = dependencies.readManifest ?? ((path: string) => readFileSync(path, 'utf8'));
|
|
292
|
+
const pathExists = dependencies.pathExists ?? existsSync;
|
|
293
|
+
let manifestPath: string;
|
|
294
|
+
try {
|
|
295
|
+
manifestPath = resolvePackageJson(FUSION_ANTHROPIC_SANITIZER_MANIFEST);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
throw new FusionError(
|
|
298
|
+
`Anthropic sanitizer package ${FUSION_ANTHROPIC_SANITIZER_PACKAGE} could not be resolved: ${error instanceof Error ? error.message : String(error)}. Claude children cannot be launched without it.`,
|
|
299
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
let manifestText: string;
|
|
303
|
+
try {
|
|
304
|
+
manifestText = readManifest(manifestPath);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
throw new FusionError(
|
|
307
|
+
`Anthropic sanitizer manifest ${manifestPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
308
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const entry = manifestExtensionEntry(manifestText, manifestPath);
|
|
312
|
+
const extensionPath = resolve(dirname(manifestPath), entry);
|
|
313
|
+
if (!pathExists(extensionPath)) {
|
|
314
|
+
throw new FusionError(
|
|
315
|
+
`Anthropic sanitizer extension is missing: ${extensionPath} (declared by ${manifestPath})`,
|
|
316
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return extensionPath;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function assertFusionToolPolicyDisjoint(
|
|
323
|
+
allowlist: readonly string[] = FUSION_INSPECT_TOOLS,
|
|
324
|
+
denylist: readonly string[] = FUSION_FORBIDDEN_TOOLS,
|
|
325
|
+
): void {
|
|
326
|
+
for (const forbidden of denylist) {
|
|
327
|
+
if (allowlist.includes(forbidden)) {
|
|
328
|
+
throw new FusionError(
|
|
329
|
+
`fusion inspect capability would enable the forbidden tool ${forbidden}`,
|
|
330
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function researchToolAllowlist(): readonly string[] {
|
|
337
|
+
return [...FUSION_INSPECT_TOOLS, FUSION_WEB_FETCH_TOOL_NAME];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function fusionToolArgv(capability: FusionCapability): string[] {
|
|
341
|
+
if (capability === 'reason') return ['--no-tools'];
|
|
342
|
+
if (capability === 'inspect') {
|
|
343
|
+
assertFusionToolPolicyDisjoint(FUSION_INSPECT_TOOLS);
|
|
344
|
+
return [
|
|
345
|
+
'--no-builtin-tools',
|
|
346
|
+
'--tools',
|
|
347
|
+
FUSION_INSPECT_TOOLS.join(','),
|
|
348
|
+
'--exclude-tools',
|
|
349
|
+
FUSION_FORBIDDEN_TOOLS.join(','),
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
if (capability === 'research') {
|
|
353
|
+
const allowlist = researchToolAllowlist();
|
|
354
|
+
assertFusionToolPolicyDisjoint(allowlist);
|
|
355
|
+
return [
|
|
356
|
+
'--no-builtin-tools',
|
|
357
|
+
'--tools',
|
|
358
|
+
allowlist.join(','),
|
|
359
|
+
'--exclude-tools',
|
|
360
|
+
FUSION_FORBIDDEN_TOOLS.join(','),
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
throw new FusionError(`fusion capability ${String(capability)} is not supported`, {
|
|
364
|
+
code: 'orchestration_failed',
|
|
365
|
+
childCreated: false,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Extensions explicitly loaded into a child, in deterministic order.
|
|
371
|
+
*
|
|
372
|
+
* `--no-extensions` disables discovery but still honours explicit `--extension`
|
|
373
|
+
* paths, so this list is the complete set a child receives. The metadata
|
|
374
|
+
* extension is always present; the Anthropic sanitizer is appended only for
|
|
375
|
+
* Claude routes, keeping non-Anthropic child argv byte-identical to before.
|
|
376
|
+
*/
|
|
377
|
+
export function fusionChildExtensionPaths(
|
|
378
|
+
model: ResolvedFusionModel,
|
|
379
|
+
childExtensionPath: string,
|
|
380
|
+
resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
|
|
381
|
+
): readonly string[] {
|
|
382
|
+
if (model.provider !== FUSION_SANITIZED_PROVIDER) return [childExtensionPath];
|
|
383
|
+
return [childExtensionPath, resolveSanitizer()];
|
|
384
|
+
}
|
|
385
|
+
|
|
189
386
|
export function buildFusionPiChildArgv(
|
|
190
387
|
model: ResolvedFusionModel,
|
|
191
388
|
systemPrompt: string,
|
|
192
389
|
childExtensionPath = resolveFusionChildExtensionPath(),
|
|
390
|
+
capability: FusionCapability = FUSION_DEFAULT_CAPABILITY,
|
|
391
|
+
resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
|
|
193
392
|
): string[] {
|
|
393
|
+
const extensionArgs = fusionChildExtensionPaths(
|
|
394
|
+
model,
|
|
395
|
+
childExtensionPath,
|
|
396
|
+
resolveSanitizer,
|
|
397
|
+
).flatMap((path) => ['--extension', path]);
|
|
194
398
|
return [
|
|
195
399
|
'--mode',
|
|
196
400
|
'text',
|
|
197
401
|
'--no-session',
|
|
198
|
-
|
|
402
|
+
...fusionToolArgv(capability),
|
|
199
403
|
'--no-extensions',
|
|
200
404
|
'--no-skills',
|
|
201
405
|
'--no-prompt-templates',
|
|
202
406
|
'--no-themes',
|
|
203
407
|
'--no-context-files',
|
|
204
|
-
|
|
205
|
-
childExtensionPath,
|
|
408
|
+
...extensionArgs,
|
|
206
409
|
'--provider',
|
|
207
410
|
model.provider,
|
|
208
411
|
'--model',
|
|
@@ -237,6 +440,29 @@ function assertClosedRecord(
|
|
|
237
440
|
return value;
|
|
238
441
|
}
|
|
239
442
|
|
|
443
|
+
function assertClosedRecordWithOptional(
|
|
444
|
+
value: unknown,
|
|
445
|
+
requiredKeys: readonly string[],
|
|
446
|
+
optionalKeys: readonly string[],
|
|
447
|
+
label: string,
|
|
448
|
+
): Record<PropertyKey, unknown> {
|
|
449
|
+
if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
450
|
+
const allowed = new Set([...requiredKeys, ...optionalKeys]);
|
|
451
|
+
const actual = Object.keys(value).sort();
|
|
452
|
+
const missing = requiredKeys.filter((key) => !Object.hasOwn(value, key));
|
|
453
|
+
const unknownKeys = actual.filter((key) => !allowed.has(key));
|
|
454
|
+
if (missing.length > 0 || unknownKeys.length > 0) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`${label} keys mismatch: required ${[...requiredKeys].sort().join(', ')}; optional ${[
|
|
457
|
+
...optionalKeys,
|
|
458
|
+
]
|
|
459
|
+
.sort()
|
|
460
|
+
.join(', ')}`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return value;
|
|
464
|
+
}
|
|
465
|
+
|
|
240
466
|
function requireNonBlankString(
|
|
241
467
|
record: Record<PropertyKey, unknown>,
|
|
242
468
|
key: string,
|
|
@@ -371,6 +597,135 @@ export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr
|
|
|
371
597
|
return { records, events, diagnostics: Buffer.concat(diagnostics) };
|
|
372
598
|
}
|
|
373
599
|
|
|
600
|
+
function parseToolCallLogRecord(value: unknown, label: string): FusionToolCallLogRecord {
|
|
601
|
+
const record = assertClosedRecordWithOptional(
|
|
602
|
+
value,
|
|
603
|
+
[
|
|
604
|
+
'schema_version',
|
|
605
|
+
'ordinal',
|
|
606
|
+
'tool_name',
|
|
607
|
+
'arguments_sha256',
|
|
608
|
+
'arguments_bytes',
|
|
609
|
+
'result_bytes',
|
|
610
|
+
'result_sha256',
|
|
611
|
+
'status',
|
|
612
|
+
'duration_ms',
|
|
613
|
+
],
|
|
614
|
+
['url', 'final_url', 'http_status', 'response_bytes', 'content_sha256'],
|
|
615
|
+
label,
|
|
616
|
+
);
|
|
617
|
+
if (record['schema_version'] !== FUSION_TOOL_CALL_LOG_SCHEMA_VERSION) {
|
|
618
|
+
throw new Error(`${label}.schema_version mismatch`);
|
|
619
|
+
}
|
|
620
|
+
const status = record['status'];
|
|
621
|
+
if (status !== 'ok' && status !== 'error') throw new Error(`${label}.status is invalid`);
|
|
622
|
+
const parsedRecord: FusionToolCallLogRecord = {
|
|
623
|
+
schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
624
|
+
ordinal: requireUsageInteger(record, 'ordinal', label),
|
|
625
|
+
tool_name: requireNonBlankString(record, 'tool_name', label),
|
|
626
|
+
arguments_sha256: requireSha256(record, 'arguments_sha256', label),
|
|
627
|
+
arguments_bytes: requireUsageInteger(record, 'arguments_bytes', label),
|
|
628
|
+
result_bytes: requireUsageInteger(record, 'result_bytes', label),
|
|
629
|
+
result_sha256: requireSha256(record, 'result_sha256', label),
|
|
630
|
+
status,
|
|
631
|
+
duration_ms: requireUsageInteger(record, 'duration_ms', label),
|
|
632
|
+
};
|
|
633
|
+
if (record['url'] !== undefined) parsedRecord.url = requireNonBlankString(record, 'url', label);
|
|
634
|
+
if (record['final_url'] !== undefined)
|
|
635
|
+
parsedRecord.final_url = requireNonBlankString(record, 'final_url', label);
|
|
636
|
+
if (record['http_status'] !== undefined)
|
|
637
|
+
parsedRecord.http_status = requireUsageInteger(record, 'http_status', label);
|
|
638
|
+
if (record['response_bytes'] !== undefined)
|
|
639
|
+
parsedRecord.response_bytes = requireUsageInteger(record, 'response_bytes', label);
|
|
640
|
+
if (record['content_sha256'] !== undefined)
|
|
641
|
+
parsedRecord.content_sha256 = requireSha256(record, 'content_sha256', label);
|
|
642
|
+
return parsedRecord;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function parseFusionToolCallLog(bytes: Buffer): FusionToolCallTrace {
|
|
646
|
+
if (bytes.length === 0) {
|
|
647
|
+
return {
|
|
648
|
+
bytes,
|
|
649
|
+
records: [],
|
|
650
|
+
summary: { count: 0, total_result_bytes: 0, trace_complete: true },
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (bytes.at(-1) !== 10) {
|
|
654
|
+
throw new Error('fusion tool-call log has trailing partial line');
|
|
655
|
+
}
|
|
656
|
+
const text = bytes.toString('utf8');
|
|
657
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
658
|
+
throw new Error('fusion tool-call log is not valid UTF-8');
|
|
659
|
+
}
|
|
660
|
+
const lines = text.split('\n');
|
|
661
|
+
lines.pop();
|
|
662
|
+
const records = lines.map((line, index) => {
|
|
663
|
+
let parsed: unknown;
|
|
664
|
+
try {
|
|
665
|
+
parsed = parseJsonText(line);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`fusion tool-call log line ${String(index)} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return parseToolCallLogRecord(parsed, `fusion tool-call log line ${String(index)}`);
|
|
672
|
+
});
|
|
673
|
+
const seen = new Set<number>();
|
|
674
|
+
for (const [index, record] of records.entries()) {
|
|
675
|
+
if (seen.has(record.ordinal)) {
|
|
676
|
+
throw new Error(`fusion tool-call log duplicate ordinal ${String(record.ordinal)}`);
|
|
677
|
+
}
|
|
678
|
+
seen.add(record.ordinal);
|
|
679
|
+
if (record.ordinal !== index) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`fusion tool-call log ordinal gap: expected ${String(index)}, observed ${String(record.ordinal)}`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
bytes,
|
|
687
|
+
records,
|
|
688
|
+
summary: {
|
|
689
|
+
count: records.length,
|
|
690
|
+
total_result_bytes: records.reduce((sum, record) => sum + record.result_bytes, 0),
|
|
691
|
+
trace_complete: true,
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function isNotFound(error: unknown): boolean {
|
|
697
|
+
return isJsonObject(error) && error['code'] === 'ENOENT';
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function readFusionToolCallLog(path: string): Promise<FusionToolCallTrace> {
|
|
701
|
+
let bytes: Buffer;
|
|
702
|
+
try {
|
|
703
|
+
bytes = await readFile(path);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
// The child extension creates this file before tools can run, so a missing file
|
|
706
|
+
// means the audit trail was never established - not that zero tools were used. Those
|
|
707
|
+
// must stay distinguishable: silently accepting absence would let a run whose activity
|
|
708
|
+
// was never recorded report success, defeating the purpose of the log.
|
|
709
|
+
if (isNotFound(error)) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`fusion tool-call log is missing at ${path}; the inspect child never initialized its audit trail`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
// The audit trail must be a real file inside the run directory. A symlink here would let
|
|
717
|
+
// anything able to pre-create the path redirect the parent's read elsewhere, so the type
|
|
718
|
+
// is checked explicitly rather than trusting the 0700 run directory alone. lstat does not
|
|
719
|
+
// follow the link, so a symlinked path is rejected instead of silently resolved.
|
|
720
|
+
const stats = await lstat(path);
|
|
721
|
+
if (!stats.isFile()) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`fusion tool-call log at ${path} is not a regular file; refusing to trust a redirected audit trail`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
return parseFusionToolCallLog(bytes);
|
|
727
|
+
}
|
|
728
|
+
|
|
374
729
|
function sha256Buffer(bytes: Buffer): string {
|
|
375
730
|
return createHash('sha256').update(bytes).digest('hex');
|
|
376
731
|
}
|
|
@@ -436,8 +791,7 @@ export class FusionPiCompactResultParser {
|
|
|
436
791
|
const final = parsed.records.at(-1);
|
|
437
792
|
if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
|
|
438
793
|
for (const record of parsed.records) this.assertModel(record);
|
|
439
|
-
|
|
440
|
-
throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
|
|
794
|
+
this.assertTranscriptStopReasons(parsed.records);
|
|
441
795
|
const observed = this.observedFromRecords(parsed.records);
|
|
442
796
|
return {
|
|
443
797
|
text: reconstructFinalText(response, final),
|
|
@@ -458,6 +812,43 @@ export class FusionPiCompactResultParser {
|
|
|
458
812
|
}
|
|
459
813
|
}
|
|
460
814
|
|
|
815
|
+
private assertTranscriptStopReasons(records: readonly FusionChildResultMetadata[]): void {
|
|
816
|
+
for (const [index, record] of records.entries()) {
|
|
817
|
+
const isFinal = index === records.length - 1;
|
|
818
|
+
if (isFinal) {
|
|
819
|
+
if (record.stop_reason !== 'stop') {
|
|
820
|
+
throw new Error(this.stopReasonError('final', 'stop', record.stop_reason, true));
|
|
821
|
+
}
|
|
822
|
+
} else if (record.stop_reason !== 'toolUse') {
|
|
823
|
+
throw new Error(
|
|
824
|
+
this.stopReasonError(`non-final record ${index}`, 'toolUse', record.stop_reason, true),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private stopReasonError(
|
|
831
|
+
position: string,
|
|
832
|
+
expected: string,
|
|
833
|
+
observed: string,
|
|
834
|
+
includeStopDetail: boolean,
|
|
835
|
+
): string {
|
|
836
|
+
const prefix = `Pi ${position} stop reason is not ${expected}: ${observed}`;
|
|
837
|
+
if (!includeStopDetail) return prefix;
|
|
838
|
+
switch (observed) {
|
|
839
|
+
case 'length':
|
|
840
|
+
return `${prefix} (model output was truncated)`;
|
|
841
|
+
case 'error':
|
|
842
|
+
return `${prefix} (Pi reported an error stop)`;
|
|
843
|
+
case 'aborted':
|
|
844
|
+
return `${prefix} (Pi reported an aborted stop)`;
|
|
845
|
+
case 'pending':
|
|
846
|
+
return `${prefix} (Pi reported a pending stop)`;
|
|
847
|
+
default:
|
|
848
|
+
return prefix;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
461
852
|
private observedFromRecords(
|
|
462
853
|
records: readonly FusionChildResultMetadata[],
|
|
463
854
|
): ObservedChildSnapshot {
|
|
@@ -542,8 +933,8 @@ function defaultSpawn(command: string, args: string[], options: SpawnOptions): F
|
|
|
542
933
|
/**
|
|
543
934
|
* Termination timers must keep the event loop alive.
|
|
544
935
|
*
|
|
545
|
-
* The SIGTERM grace, SIGKILL wait,
|
|
546
|
-
* things that settle the run promise when a child stops emitting events. An
|
|
936
|
+
* The SIGTERM grace, SIGKILL wait, overall timeout, and idle timeout timers
|
|
937
|
+
* are the only things that settle the run promise when a child stops emitting events. An
|
|
547
938
|
* unref'd timer lets the loop drain first, leaving the promise pending forever
|
|
548
939
|
* ("Promise resolution is still pending but the event loop has already
|
|
549
940
|
* resolved"). Every timer stored here is cleared in the `finally` of
|
|
@@ -646,9 +1037,11 @@ function cleanupTimers(state: ProcessState): void {
|
|
|
646
1037
|
if (state.termTimer !== undefined) clearTimeout(state.termTimer);
|
|
647
1038
|
if (state.waitTimer !== undefined) clearTimeout(state.waitTimer);
|
|
648
1039
|
if (state.timeoutTimer !== undefined) clearTimeout(state.timeoutTimer);
|
|
1040
|
+
if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
|
|
649
1041
|
state.termTimer = undefined;
|
|
650
1042
|
state.waitTimer = undefined;
|
|
651
1043
|
state.timeoutTimer = undefined;
|
|
1044
|
+
state.idleTimer = undefined;
|
|
652
1045
|
}
|
|
653
1046
|
|
|
654
1047
|
async function writePromptToStdin(child: FusionChildProcess, prompt: string): Promise<void> {
|
|
@@ -692,16 +1085,32 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
692
1085
|
const spawnImpl = options.spawn ?? defaultSpawn;
|
|
693
1086
|
const killProcess = options.killProcess ?? process.kill.bind(process);
|
|
694
1087
|
const platform = options.platform ?? process.platform;
|
|
1088
|
+
const capability = options.capability ?? FUSION_DEFAULT_CAPABILITY;
|
|
695
1089
|
const env = fusionPiChildEnv(options.env ?? process.env);
|
|
1090
|
+
if (capability !== 'reason') {
|
|
1091
|
+
if (options.toolCallLogPath === undefined) {
|
|
1092
|
+
throw childError(
|
|
1093
|
+
`fusion ${capability} child requires a tool-call log path`,
|
|
1094
|
+
'orchestration_failed',
|
|
1095
|
+
options,
|
|
1096
|
+
false,
|
|
1097
|
+
false,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
env[FUSION_TOOL_CALL_LOG_PATH_ENV] = options.toolCallLogPath;
|
|
1101
|
+
if (capability === 'research') env[FUSION_RESEARCH_ENABLED_ENV] = '1';
|
|
1102
|
+
}
|
|
696
1103
|
const stdoutLimit = options.stdoutLimitBytes ?? FUSION_CHILD_STDOUT_LIMIT_BYTES;
|
|
697
1104
|
const stderrLimit = options.stderrLimitBytes ?? FUSION_CHILD_STDERR_LIMIT_BYTES;
|
|
698
1105
|
const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
|
|
1106
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? FUSION_CHILD_IDLE_TIMEOUT_MS;
|
|
699
1107
|
const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
|
|
700
1108
|
const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
|
|
701
1109
|
const argv = buildFusionPiChildArgv(
|
|
702
1110
|
options.model,
|
|
703
1111
|
options.systemPrompt,
|
|
704
1112
|
options.childExtensionPath ?? resolveFusionChildExtensionPath(),
|
|
1113
|
+
capability,
|
|
705
1114
|
);
|
|
706
1115
|
const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
|
|
707
1116
|
const stdoutChunks: Buffer[] = [];
|
|
@@ -715,6 +1124,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
715
1124
|
termTimer: undefined,
|
|
716
1125
|
waitTimer: undefined,
|
|
717
1126
|
timeoutTimer: undefined,
|
|
1127
|
+
idleTimer: undefined,
|
|
718
1128
|
settled: false,
|
|
719
1129
|
};
|
|
720
1130
|
|
|
@@ -754,6 +1164,23 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
754
1164
|
};
|
|
755
1165
|
});
|
|
756
1166
|
|
|
1167
|
+
const resetIdleTimer = () => {
|
|
1168
|
+
if (state.settled) return;
|
|
1169
|
+
if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
|
|
1170
|
+
state.idleTimer = trackTimer(
|
|
1171
|
+
setTimeout(() => {
|
|
1172
|
+
if (state.settled) return;
|
|
1173
|
+
if (state.primaryError === undefined) {
|
|
1174
|
+
state.primaryError = childError(
|
|
1175
|
+
`Pi child produced no output for ${String(idleTimeoutMs)}ms (stalled)`,
|
|
1176
|
+
'child_timeout',
|
|
1177
|
+
options,
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
1181
|
+
}, idleTimeoutMs),
|
|
1182
|
+
);
|
|
1183
|
+
};
|
|
757
1184
|
const abortListener = () => {
|
|
758
1185
|
if (state.settled) return;
|
|
759
1186
|
if (state.primaryError === undefined) {
|
|
@@ -762,6 +1189,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
762
1189
|
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
763
1190
|
};
|
|
764
1191
|
const stdoutListener = (data: Buffer | string) => {
|
|
1192
|
+
resetIdleTimer();
|
|
765
1193
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
766
1194
|
const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
|
|
767
1195
|
stdoutBytes = appended.bytes;
|
|
@@ -775,6 +1203,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
775
1203
|
}
|
|
776
1204
|
};
|
|
777
1205
|
const stderrListener = (data: Buffer | string) => {
|
|
1206
|
+
resetIdleTimer();
|
|
778
1207
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
779
1208
|
const appended = appendCapped(stderrChunks, stderrBytes, chunk, stderrLimit);
|
|
780
1209
|
stderrBytes = appended.bytes;
|
|
@@ -812,6 +1241,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
812
1241
|
child.once('close', closeListener);
|
|
813
1242
|
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
814
1243
|
if (options.signal?.aborted) abortListener();
|
|
1244
|
+
resetIdleTimer();
|
|
815
1245
|
state.timeoutTimer = trackTimer(
|
|
816
1246
|
setTimeout(() => {
|
|
817
1247
|
if (state.primaryError === undefined) {
|
|
@@ -900,6 +1330,40 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
900
1330
|
observed,
|
|
901
1331
|
);
|
|
902
1332
|
}
|
|
1333
|
+
let toolCallTrace: FusionToolCallTrace | undefined;
|
|
1334
|
+
if (capability !== 'reason') {
|
|
1335
|
+
// The launch path above refuses to spawn a tool-enabled child without a log path, so
|
|
1336
|
+
// this is unreachable. Assert rather than defaulting: a `?? ''` here would silently
|
|
1337
|
+
// read an empty path if that guard were ever refactored away, turning a missing
|
|
1338
|
+
// audit trail into a successful run.
|
|
1339
|
+
const logPath = options.toolCallLogPath;
|
|
1340
|
+
if (logPath === undefined) {
|
|
1341
|
+
throw childError(
|
|
1342
|
+
`fusion ${capability} child completed without a tool-call log path`,
|
|
1343
|
+
'orchestration_failed',
|
|
1344
|
+
options,
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
toolCallTrace = await readFusionToolCallLog(logPath);
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
throw new FusionChildRunError(
|
|
1351
|
+
withCleanupErrors(
|
|
1352
|
+
childError(
|
|
1353
|
+
`Pi child tool-call log invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
1354
|
+
'child_event_invalid',
|
|
1355
|
+
options,
|
|
1356
|
+
),
|
|
1357
|
+
state.cleanupErrors,
|
|
1358
|
+
),
|
|
1359
|
+
compactEvents,
|
|
1360
|
+
response,
|
|
1361
|
+
diagnostics,
|
|
1362
|
+
close,
|
|
1363
|
+
observed,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
903
1367
|
const result: FusionChildRunResult = {
|
|
904
1368
|
stage: options.stage,
|
|
905
1369
|
attempt: options.attempt,
|
|
@@ -914,6 +1378,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
914
1378
|
signal: close.signal,
|
|
915
1379
|
};
|
|
916
1380
|
if (options.slot !== undefined) result.slot = options.slot;
|
|
1381
|
+
if (toolCallTrace !== undefined) result.toolCallTrace = toolCallTrace;
|
|
917
1382
|
return result;
|
|
918
1383
|
} finally {
|
|
919
1384
|
cleanupTimers(state);
|