pi-background-tasks 0.7.7 → 1.0.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/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
- package/PUBLISHING.md +43 -29
- package/README.md +234 -385
- package/TESTING.md +15 -9
- package/TEST_PLAN.md +46 -13
- package/docs/INDEX.md +157 -0
- package/docs/api/eventbus-v1.md +166 -0
- package/docs/assets/architecture.svg +78 -0
- package/docs/assets/footer-dock.svg +47 -0
- package/docs/assets/logo.svg +49 -0
- package/docs/attestations.json +189 -0
- package/docs/choose-a-workflow.md +98 -0
- package/docs/commands/bg-clear.md +70 -0
- package/docs/commands/bg-update.md +82 -0
- package/docs/commands/bg.md +90 -0
- package/docs/commands/fusion-models.md +70 -0
- package/docs/commands/fusion.md +69 -0
- package/docs/commands/jobs.md +74 -0
- package/docs/commands/kill.md +82 -0
- package/docs/commands/logs.md +90 -0
- package/docs/commands/task-manager.md +109 -0
- package/docs/concepts/completion-delivery.md +66 -0
- package/docs/concepts/context-projection-and-budgeting.md +79 -0
- package/docs/getting-started.md +122 -0
- package/docs/manifest.json +1825 -0
- package/docs/operations/configuration.md +110 -0
- package/docs/operations/releasing.md +67 -0
- package/docs/operations/testing.md +101 -0
- package/docs/operations/troubleshooting.md +38 -0
- package/docs/read-before-edit.md +94 -0
- package/docs/reference/runtime-contracts.md +213 -0
- package/docs/reference/shortcuts-and-dock.md +70 -0
- package/docs/subsystems/attested-pi-runs.md +141 -0
- package/docs/subsystems/background-task-runtime.md +85 -0
- package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
- package/docs/subsystems/delegation.md +190 -0
- package/docs/subsystems/docs-freshness-gate.md +26 -0
- package/docs/subsystems/fusion.md +121 -0
- package/docs/subsystems/host-ui-and-telemetry.md +83 -0
- package/docs/tools/bg_delegate.md +193 -0
- package/docs/tools/bg_kill.md +114 -0
- package/docs/tools/bg_logs.md +133 -0
- package/docs/tools/bg_result.md +120 -0
- package/docs/tools/bg_run.md +168 -0
- package/docs/tools/bg_run_pi_attested.md +170 -0
- package/docs/tools/bg_status.md +111 -0
- package/docs/tools/fusion_investigate.md +116 -0
- package/docs/tools/fusion_reason.md +75 -0
- package/docs/tools/fusion_research.md +162 -0
- package/docs/tools/fusion_validate.md +206 -0
- package/logo.png +0 -0
- package/package.json +29 -6
- package/src/core/delegate/budget.ts +1 -1
- package/src/core/delegate/launch.ts +6 -0
- package/src/core/fusion/artifacts.ts +80 -5
- package/src/core/fusion/budget.ts +129 -28
- package/src/core/fusion/child-protocol.ts +82 -0
- package/src/core/fusion/clean-context.ts +91 -0
- package/src/core/fusion/config.ts +124 -35
- package/src/core/fusion/context.ts +33 -6
- package/src/core/fusion/evaluation.ts +392 -15
- package/src/core/fusion/orchestrator.ts +274 -25
- package/src/core/fusion/pi-child.ts +635 -10
- package/src/core/fusion/prompts.ts +167 -6
- package/src/core/fusion/source-policy.ts +257 -0
- package/src/core/fusion/types.ts +232 -5
- package/src/core/fusion/web-fetch.ts +993 -0
- package/src/core/fusion/workflows.ts +184 -0
- package/src/extension.ts +3 -3
- package/src/fusion-child-extension.ts +370 -54
- package/src/fusion-extension.ts +625 -125
- package/src/testing/normalize.ts +0 -22
|
@@ -1,25 +1,44 @@
|
|
|
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 { constants, existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { open } 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_SOURCE_POLICY_PATH_ENV,
|
|
13
|
+
FUSION_SOURCE_POLICY_SHA256_ENV,
|
|
14
|
+
FUSION_TOOL_CALL_LOG_PATH_ENV,
|
|
15
|
+
FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
|
|
16
|
+
FUSION_TOOL_CALL_SEAL_SUFFIX,
|
|
17
|
+
FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
|
|
9
18
|
type FusionChildResultMetadata,
|
|
10
|
-
} from '
|
|
19
|
+
} from './child-protocol.js';
|
|
11
20
|
import {
|
|
21
|
+
FUSION_FORBIDDEN_TOOLS,
|
|
22
|
+
FUSION_NO_TOOLS_CAPABILITY,
|
|
23
|
+
FUSION_INSPECT_TOOLS,
|
|
24
|
+
FUSION_RESEARCH_TOOLS,
|
|
25
|
+
FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
26
|
+
FUSION_WEB_FETCH_TOOL_NAME,
|
|
12
27
|
FusionError,
|
|
13
28
|
addFusionUsage,
|
|
14
29
|
cloneFusionUsage,
|
|
15
30
|
createEmptyFusionUsage,
|
|
31
|
+
type FusionCapability,
|
|
16
32
|
type FusionChildRunResult,
|
|
17
33
|
type FusionErrorDetails,
|
|
18
34
|
type FusionStage,
|
|
35
|
+
type FusionToolCallLogRecord,
|
|
36
|
+
type FusionToolCallTrace,
|
|
19
37
|
type FusionUsage,
|
|
20
38
|
type ResolvedFusionModel,
|
|
21
39
|
} from './types.js';
|
|
22
40
|
import { isJsonObject, parseJsonText } from '../common.js';
|
|
41
|
+
import { canonicalizeFusionPublicUrl, readFusionSourcePolicyFile } from './source-policy.js';
|
|
23
42
|
import {
|
|
24
43
|
assertWindowsCommandLineWithinLimit,
|
|
25
44
|
piLaunchArgv,
|
|
@@ -31,8 +50,21 @@ import {
|
|
|
31
50
|
export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
32
51
|
export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
33
52
|
export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
53
|
+
/**
|
|
54
|
+
* Stale-action watchdog threshold.
|
|
55
|
+
*
|
|
56
|
+
* Activity is one stdout or stderr byte from the child. The child extension emits its
|
|
57
|
+
* compact metadata frame only at `message_end`, and Pi text mode writes stdout only for
|
|
58
|
+
* the final assistant message, so a single slow model turn is genuinely silent on both
|
|
59
|
+
* streams. The threshold must therefore exceed the longest plausible single turn, not the
|
|
60
|
+
* longest plausible tool call: a value tuned to tool latency would kill healthy children
|
|
61
|
+
* mid-reasoning. 1200s stays inside the 30-minute absolute cap while leaving a wide
|
|
62
|
+
* margin over observed turn latency.
|
|
63
|
+
*/
|
|
64
|
+
export const FUSION_CHILD_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
34
65
|
export const FUSION_CHILD_KILL_GRACE_MS = 3000;
|
|
35
66
|
export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
|
|
67
|
+
const FUSION_PI_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
36
68
|
|
|
37
69
|
export const FUSION_CHILD_REMOVED_ENV_KEYS = [
|
|
38
70
|
'PI_SESSION_ID',
|
|
@@ -40,6 +72,27 @@ export const FUSION_CHILD_REMOVED_ENV_KEYS = [
|
|
|
40
72
|
'PI_PROVIDER',
|
|
41
73
|
'PI_MODEL',
|
|
42
74
|
'PI_REASONING_LEVEL',
|
|
75
|
+
'OPENROUTER_API_KEY',
|
|
76
|
+
'OPENROUTER_BASE_URL',
|
|
77
|
+
'OPENAI_API_KEY',
|
|
78
|
+
'OPENAI_BASE_URL',
|
|
79
|
+
'ANTHROPIC_API_KEY',
|
|
80
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
81
|
+
'ANTHROPIC_BASE_URL',
|
|
82
|
+
'AZURE_OPENAI_API_KEY',
|
|
83
|
+
'AZURE_OPENAI_BASE_URL',
|
|
84
|
+
'AZURE_OPENAI_ENDPOINT',
|
|
85
|
+
'AZURE_OPENAI_RESOURCE_NAME',
|
|
86
|
+
'AZURE_OPENAI_API_VERSION',
|
|
87
|
+
'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
|
|
88
|
+
'AZURE_OPENAI_AD_TOKEN',
|
|
89
|
+
'PI_API_KEY',
|
|
90
|
+
'PI_API_BASE_URL',
|
|
91
|
+
'PI_AUTH_FILE',
|
|
92
|
+
FUSION_TOOL_CALL_LOG_PATH_ENV,
|
|
93
|
+
FUSION_RESEARCH_ENABLED_ENV,
|
|
94
|
+
FUSION_SOURCE_POLICY_PATH_ENV,
|
|
95
|
+
FUSION_SOURCE_POLICY_SHA256_ENV,
|
|
43
96
|
] as const;
|
|
44
97
|
|
|
45
98
|
interface FusionReadableStream {
|
|
@@ -86,6 +139,7 @@ export interface RunPiChildOptions {
|
|
|
86
139
|
attempt: number;
|
|
87
140
|
cwd: string;
|
|
88
141
|
model: ResolvedFusionModel;
|
|
142
|
+
capability?: FusionCapability | undefined;
|
|
89
143
|
systemPrompt: string;
|
|
90
144
|
userPrompt: string;
|
|
91
145
|
signal?: AbortSignal | undefined;
|
|
@@ -97,9 +151,12 @@ export interface RunPiChildOptions {
|
|
|
97
151
|
childExtensionPath?: string | undefined;
|
|
98
152
|
stderrLimitBytes?: number | undefined;
|
|
99
153
|
timeoutMs?: number | undefined;
|
|
154
|
+
idleTimeoutMs?: number | undefined;
|
|
100
155
|
killGraceMs?: number | undefined;
|
|
101
156
|
sigkillWaitMs?: number | undefined;
|
|
102
157
|
piLaunchDependencies?: PiLaunchDependencies | undefined;
|
|
158
|
+
toolCallLogPath?: string | undefined;
|
|
159
|
+
sourcePolicy?: { path: string; sha256: string } | undefined;
|
|
103
160
|
}
|
|
104
161
|
|
|
105
162
|
interface CloseRecord {
|
|
@@ -114,6 +171,7 @@ interface ProcessState {
|
|
|
114
171
|
termTimer: NodeJS.Timeout | undefined;
|
|
115
172
|
waitTimer: NodeJS.Timeout | undefined;
|
|
116
173
|
timeoutTimer: NodeJS.Timeout | undefined;
|
|
174
|
+
idleTimer: NodeJS.Timeout | undefined;
|
|
117
175
|
settled: boolean;
|
|
118
176
|
}
|
|
119
177
|
|
|
@@ -168,7 +226,10 @@ export class FusionChildRunError extends FusionError {
|
|
|
168
226
|
|
|
169
227
|
export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
170
228
|
const out: NodeJS.ProcessEnv = { ...env };
|
|
171
|
-
|
|
229
|
+
const removed = new Set<string>(FUSION_CHILD_REMOVED_ENV_KEYS);
|
|
230
|
+
for (const inheritedKey of Object.keys(out)) {
|
|
231
|
+
if (removed.has(inheritedKey.toUpperCase())) Reflect.deleteProperty(out, inheritedKey);
|
|
232
|
+
}
|
|
172
233
|
out['PI_SKIP_VERSION_CHECK'] = '1';
|
|
173
234
|
return out;
|
|
174
235
|
}
|
|
@@ -186,23 +247,198 @@ export function resolveFusionChildExtensionPath(
|
|
|
186
247
|
return candidate;
|
|
187
248
|
}
|
|
188
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Provider whose children require the Anthropic system-prompt sanitizer.
|
|
252
|
+
*
|
|
253
|
+
* Pi's own system prompt contains documentation lines that Anthropic rejects, so a
|
|
254
|
+
* Claude child launched without the sanitizer fails at the provider rather than
|
|
255
|
+
* producing an answer. The parent session loads the sanitizer through ordinary
|
|
256
|
+
* extension discovery, but Fusion children run with `--no-extensions` for
|
|
257
|
+
* isolation and therefore inherit nothing; the sanitizer must be re-supplied
|
|
258
|
+
* explicitly per child.
|
|
259
|
+
*/
|
|
260
|
+
export const FUSION_SANITIZED_PROVIDER = 'anthropic';
|
|
261
|
+
export const FUSION_ANTHROPIC_SANITIZER_PACKAGE = '@ravshansbox/pi-anthropic-sps';
|
|
262
|
+
const FUSION_ANTHROPIC_SANITIZER_MANIFEST = `${FUSION_ANTHROPIC_SANITIZER_PACKAGE}/package.json`;
|
|
263
|
+
|
|
264
|
+
export interface FusionSanitizerDependencies {
|
|
265
|
+
resolvePackageJson?: ((specifier: string) => string) | undefined;
|
|
266
|
+
readManifest?: ((path: string) => string) | undefined;
|
|
267
|
+
pathExists?: ((path: string) => boolean) | undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function manifestExtensionEntry(manifestText: string, manifestPath: string): string {
|
|
271
|
+
let parsed: unknown;
|
|
272
|
+
try {
|
|
273
|
+
parsed = parseJsonText(manifestText);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new FusionError(
|
|
276
|
+
`Anthropic sanitizer manifest ${manifestPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
277
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (!isJsonObject(parsed)) {
|
|
281
|
+
throw new FusionError(`Anthropic sanitizer manifest ${manifestPath} must be an object`, {
|
|
282
|
+
code: 'orchestration_failed',
|
|
283
|
+
childCreated: false,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const pi = parsed['pi'];
|
|
287
|
+
if (!isJsonObject(pi)) {
|
|
288
|
+
throw new FusionError(
|
|
289
|
+
`Anthropic sanitizer manifest ${manifestPath} has no "pi" section declaring its extension`,
|
|
290
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const extensions = pi['extensions'];
|
|
294
|
+
if (!Array.isArray(extensions) || extensions.length === 0) {
|
|
295
|
+
throw new FusionError(
|
|
296
|
+
`Anthropic sanitizer manifest ${manifestPath} declares no pi.extensions entries`,
|
|
297
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const [entry] = extensions;
|
|
301
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
302
|
+
throw new FusionError(
|
|
303
|
+
`Anthropic sanitizer manifest ${manifestPath} pi.extensions[0] must be a non-blank string`,
|
|
304
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return entry;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the sanitizer extension file shipped by the sanitizer package.
|
|
312
|
+
*
|
|
313
|
+
* The package intentionally publishes no `main`/`exports`, so the entry cannot be
|
|
314
|
+
* required directly; its manifest is resolved and the declared `pi.extensions[0]`
|
|
315
|
+
* path is joined against the package root. Every failure is loud: a Claude child
|
|
316
|
+
* launched without the sanitizer would fail at the provider with a far less
|
|
317
|
+
* actionable error, so silently omitting it is never correct.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveAnthropicSanitizerExtensionPath(
|
|
320
|
+
dependencies: FusionSanitizerDependencies = {},
|
|
321
|
+
): string {
|
|
322
|
+
const resolvePackageJson =
|
|
323
|
+
dependencies.resolvePackageJson ?? createRequire(import.meta.url).resolve;
|
|
324
|
+
const readManifest = dependencies.readManifest ?? ((path: string) => readFileSync(path, 'utf8'));
|
|
325
|
+
const pathExists = dependencies.pathExists ?? existsSync;
|
|
326
|
+
let manifestPath: string;
|
|
327
|
+
try {
|
|
328
|
+
manifestPath = resolvePackageJson(FUSION_ANTHROPIC_SANITIZER_MANIFEST);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw new FusionError(
|
|
331
|
+
`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.`,
|
|
332
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
let manifestText: string;
|
|
336
|
+
try {
|
|
337
|
+
manifestText = readManifest(manifestPath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw new FusionError(
|
|
340
|
+
`Anthropic sanitizer manifest ${manifestPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
341
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const entry = manifestExtensionEntry(manifestText, manifestPath);
|
|
345
|
+
const extensionPath = resolve(dirname(manifestPath), entry);
|
|
346
|
+
if (!pathExists(extensionPath)) {
|
|
347
|
+
throw new FusionError(
|
|
348
|
+
`Anthropic sanitizer extension is missing: ${extensionPath} (declared by ${manifestPath})`,
|
|
349
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return extensionPath;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function assertFusionToolPolicyDisjoint(
|
|
356
|
+
allowlist: readonly string[] = FUSION_INSPECT_TOOLS,
|
|
357
|
+
denylist: readonly string[] = FUSION_FORBIDDEN_TOOLS,
|
|
358
|
+
): void {
|
|
359
|
+
for (const forbidden of denylist) {
|
|
360
|
+
if (allowlist.includes(forbidden)) {
|
|
361
|
+
throw new FusionError(
|
|
362
|
+
`fusion inspect capability would enable the forbidden tool ${forbidden}`,
|
|
363
|
+
{ code: 'orchestration_failed', childCreated: false },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function researchToolAllowlist(): readonly string[] {
|
|
370
|
+
return FUSION_RESEARCH_TOOLS;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function fusionToolArgv(capability: FusionCapability): string[] {
|
|
374
|
+
if (capability === 'reason') return ['--no-tools'];
|
|
375
|
+
if (capability === 'inspect') {
|
|
376
|
+
assertFusionToolPolicyDisjoint(FUSION_INSPECT_TOOLS);
|
|
377
|
+
return [
|
|
378
|
+
'--no-builtin-tools',
|
|
379
|
+
'--tools',
|
|
380
|
+
FUSION_INSPECT_TOOLS.join(','),
|
|
381
|
+
'--exclude-tools',
|
|
382
|
+
FUSION_FORBIDDEN_TOOLS.join(','),
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
if (capability === 'research') {
|
|
386
|
+
const allowlist = researchToolAllowlist();
|
|
387
|
+
assertFusionToolPolicyDisjoint(allowlist);
|
|
388
|
+
return [
|
|
389
|
+
'--no-builtin-tools',
|
|
390
|
+
'--tools',
|
|
391
|
+
allowlist.join(','),
|
|
392
|
+
'--exclude-tools',
|
|
393
|
+
FUSION_FORBIDDEN_TOOLS.join(','),
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
throw new FusionError(`fusion capability ${String(capability)} is not supported`, {
|
|
397
|
+
code: 'orchestration_failed',
|
|
398
|
+
childCreated: false,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Extensions explicitly loaded into a child, in deterministic order.
|
|
404
|
+
*
|
|
405
|
+
* `--no-extensions` disables discovery but still honours explicit `--extension`
|
|
406
|
+
* paths, so this list is the complete set a child receives. The metadata
|
|
407
|
+
* extension is always present; the Anthropic sanitizer is appended only for
|
|
408
|
+
* Claude routes, keeping non-Anthropic child argv byte-identical to before.
|
|
409
|
+
*/
|
|
410
|
+
export function fusionChildExtensionPaths(
|
|
411
|
+
model: ResolvedFusionModel,
|
|
412
|
+
childExtensionPath: string,
|
|
413
|
+
resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
|
|
414
|
+
): readonly string[] {
|
|
415
|
+
if (model.provider !== FUSION_SANITIZED_PROVIDER) return [childExtensionPath];
|
|
416
|
+
return [childExtensionPath, resolveSanitizer()];
|
|
417
|
+
}
|
|
418
|
+
|
|
189
419
|
export function buildFusionPiChildArgv(
|
|
190
420
|
model: ResolvedFusionModel,
|
|
191
421
|
systemPrompt: string,
|
|
192
422
|
childExtensionPath = resolveFusionChildExtensionPath(),
|
|
423
|
+
capability: FusionCapability = FUSION_NO_TOOLS_CAPABILITY,
|
|
424
|
+
resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
|
|
193
425
|
): string[] {
|
|
426
|
+
const extensionArgs = fusionChildExtensionPaths(
|
|
427
|
+
model,
|
|
428
|
+
childExtensionPath,
|
|
429
|
+
resolveSanitizer,
|
|
430
|
+
).flatMap((path) => ['--extension', path]);
|
|
194
431
|
return [
|
|
195
432
|
'--mode',
|
|
196
433
|
'text',
|
|
197
434
|
'--no-session',
|
|
198
|
-
|
|
435
|
+
...fusionToolArgv(capability),
|
|
199
436
|
'--no-extensions',
|
|
200
437
|
'--no-skills',
|
|
201
438
|
'--no-prompt-templates',
|
|
202
439
|
'--no-themes',
|
|
203
440
|
'--no-context-files',
|
|
204
|
-
|
|
205
|
-
childExtensionPath,
|
|
441
|
+
...extensionArgs,
|
|
206
442
|
'--provider',
|
|
207
443
|
model.provider,
|
|
208
444
|
'--model',
|
|
@@ -237,6 +473,29 @@ function assertClosedRecord(
|
|
|
237
473
|
return value;
|
|
238
474
|
}
|
|
239
475
|
|
|
476
|
+
function assertClosedRecordWithOptional(
|
|
477
|
+
value: unknown,
|
|
478
|
+
requiredKeys: readonly string[],
|
|
479
|
+
optionalKeys: readonly string[],
|
|
480
|
+
label: string,
|
|
481
|
+
): Record<PropertyKey, unknown> {
|
|
482
|
+
if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
483
|
+
const allowed = new Set([...requiredKeys, ...optionalKeys]);
|
|
484
|
+
const actual = Object.keys(value).sort();
|
|
485
|
+
const missing = requiredKeys.filter((key) => !Object.hasOwn(value, key));
|
|
486
|
+
const unknownKeys = actual.filter((key) => !allowed.has(key));
|
|
487
|
+
if (missing.length > 0 || unknownKeys.length > 0) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`${label} keys mismatch: required ${[...requiredKeys].sort().join(', ')}; optional ${[
|
|
490
|
+
...optionalKeys,
|
|
491
|
+
]
|
|
492
|
+
.sort()
|
|
493
|
+
.join(', ')}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return value;
|
|
497
|
+
}
|
|
498
|
+
|
|
240
499
|
function requireNonBlankString(
|
|
241
500
|
record: Record<PropertyKey, unknown>,
|
|
242
501
|
key: string,
|
|
@@ -371,6 +630,253 @@ export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr
|
|
|
371
630
|
return { records, events, diagnostics: Buffer.concat(diagnostics) };
|
|
372
631
|
}
|
|
373
632
|
|
|
633
|
+
function parseToolCallLogRecord(value: unknown, label: string): FusionToolCallLogRecord {
|
|
634
|
+
const record = assertClosedRecordWithOptional(
|
|
635
|
+
value,
|
|
636
|
+
[
|
|
637
|
+
'schema_version',
|
|
638
|
+
'ordinal',
|
|
639
|
+
'tool_name',
|
|
640
|
+
'arguments_sha256',
|
|
641
|
+
'arguments_bytes',
|
|
642
|
+
'result_bytes',
|
|
643
|
+
'result_sha256',
|
|
644
|
+
'status',
|
|
645
|
+
'duration_ms',
|
|
646
|
+
],
|
|
647
|
+
['url', 'rejected_url_sha256', 'final_url', 'http_status', 'response_bytes', 'content_sha256'],
|
|
648
|
+
label,
|
|
649
|
+
);
|
|
650
|
+
if (record['schema_version'] !== FUSION_TOOL_CALL_LOG_SCHEMA_VERSION) {
|
|
651
|
+
throw new Error(`${label}.schema_version mismatch`);
|
|
652
|
+
}
|
|
653
|
+
const status = record['status'];
|
|
654
|
+
if (status !== 'ok' && status !== 'error') throw new Error(`${label}.status is invalid`);
|
|
655
|
+
const parsedRecord: FusionToolCallLogRecord = {
|
|
656
|
+
schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
657
|
+
ordinal: requireUsageInteger(record, 'ordinal', label),
|
|
658
|
+
tool_name: requireNonBlankString(record, 'tool_name', label),
|
|
659
|
+
arguments_sha256: requireSha256(record, 'arguments_sha256', label),
|
|
660
|
+
arguments_bytes: requireUsageInteger(record, 'arguments_bytes', label),
|
|
661
|
+
result_bytes: requireUsageInteger(record, 'result_bytes', label),
|
|
662
|
+
result_sha256: requireSha256(record, 'result_sha256', label),
|
|
663
|
+
status,
|
|
664
|
+
duration_ms: requireUsageInteger(record, 'duration_ms', label),
|
|
665
|
+
};
|
|
666
|
+
if (record['url'] !== undefined) parsedRecord.url = requireNonBlankString(record, 'url', label);
|
|
667
|
+
if (record['rejected_url_sha256'] !== undefined)
|
|
668
|
+
parsedRecord.rejected_url_sha256 = requireSha256(record, 'rejected_url_sha256', label);
|
|
669
|
+
if (record['final_url'] !== undefined)
|
|
670
|
+
parsedRecord.final_url = requireNonBlankString(record, 'final_url', label);
|
|
671
|
+
if (record['http_status'] !== undefined)
|
|
672
|
+
parsedRecord.http_status = requireUsageInteger(record, 'http_status', label);
|
|
673
|
+
if (record['response_bytes'] !== undefined)
|
|
674
|
+
parsedRecord.response_bytes = requireUsageInteger(record, 'response_bytes', label);
|
|
675
|
+
if (record['content_sha256'] !== undefined)
|
|
676
|
+
parsedRecord.content_sha256 = requireSha256(record, 'content_sha256', label);
|
|
677
|
+
return parsedRecord;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function parseFusionToolCallLog(bytes: Buffer): FusionToolCallTrace {
|
|
681
|
+
if (bytes.length === 0) {
|
|
682
|
+
return {
|
|
683
|
+
bytes,
|
|
684
|
+
records: [],
|
|
685
|
+
summary: { count: 0, total_result_bytes: 0, trace_complete: true },
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
if (bytes.at(-1) !== 10) {
|
|
689
|
+
throw new Error('fusion tool-call log has trailing partial line');
|
|
690
|
+
}
|
|
691
|
+
const text = bytes.toString('utf8');
|
|
692
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
693
|
+
throw new Error('fusion tool-call log is not valid UTF-8');
|
|
694
|
+
}
|
|
695
|
+
const lines = text.split('\n');
|
|
696
|
+
lines.pop();
|
|
697
|
+
const records = lines.map((line, index) => {
|
|
698
|
+
let parsed: unknown;
|
|
699
|
+
try {
|
|
700
|
+
parsed = parseJsonText(line);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`fusion tool-call log line ${String(index)} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return parseToolCallLogRecord(parsed, `fusion tool-call log line ${String(index)}`);
|
|
707
|
+
});
|
|
708
|
+
const seen = new Set<number>();
|
|
709
|
+
for (const [index, record] of records.entries()) {
|
|
710
|
+
if (seen.has(record.ordinal)) {
|
|
711
|
+
throw new Error(`fusion tool-call log duplicate ordinal ${String(record.ordinal)}`);
|
|
712
|
+
}
|
|
713
|
+
seen.add(record.ordinal);
|
|
714
|
+
if (record.ordinal !== index) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`fusion tool-call log ordinal gap: expected ${String(index)}, observed ${String(record.ordinal)}`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
bytes,
|
|
722
|
+
records,
|
|
723
|
+
summary: {
|
|
724
|
+
count: records.length,
|
|
725
|
+
total_result_bytes: records.reduce((sum, record) => sum + record.result_bytes, 0),
|
|
726
|
+
trace_complete: true,
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
async function assertCompletedToolPolicy(
|
|
733
|
+
trace: FusionToolCallTrace,
|
|
734
|
+
capability: FusionCapability,
|
|
735
|
+
sourcePolicy: { path: string; sha256: string } | undefined,
|
|
736
|
+
): Promise<void> {
|
|
737
|
+
const allowed = capability === 'inspect' ? FUSION_INSPECT_TOOLS : capability === 'research' ? FUSION_RESEARCH_TOOLS : [];
|
|
738
|
+
const allowedSet = new Set<string>(allowed);
|
|
739
|
+
const declared =
|
|
740
|
+
capability === 'research' && sourcePolicy !== undefined
|
|
741
|
+
? new Set((await readFusionSourcePolicyFile(sourcePolicy.path, sourcePolicy.sha256)).sources.map((source) => source.canonical_url))
|
|
742
|
+
: undefined;
|
|
743
|
+
for (const record of trace.records) {
|
|
744
|
+
if (!allowedSet.has(record.tool_name)) {
|
|
745
|
+
throw new Error(`fusion child used non-allowlisted tool ${record.tool_name}`);
|
|
746
|
+
}
|
|
747
|
+
if (capability === 'research' && record.tool_name === FUSION_WEB_FETCH_TOOL_NAME) {
|
|
748
|
+
if (sourcePolicy === undefined || declared === undefined) throw new Error('fusion research source policy missing during audit');
|
|
749
|
+
if (record.status === 'ok') {
|
|
750
|
+
if (record.url === undefined) throw new Error('fusion research fetch audit is missing url');
|
|
751
|
+
const canonicalUrl = canonicalizeFusionPublicUrl(record.url);
|
|
752
|
+
if (record.url !== canonicalUrl) throw new Error('fusion research fetch audit URL was not canonical');
|
|
753
|
+
if (!declared.has(canonicalUrl)) throw new Error('fusion research fetch audit URL was not declared');
|
|
754
|
+
if (record.rejected_url_sha256 !== undefined) {
|
|
755
|
+
throw new Error('fusion research successful fetch audit must not include rejected_url_sha256');
|
|
756
|
+
}
|
|
757
|
+
if (record.final_url === undefined) throw new Error('fusion research fetch audit is missing final_url');
|
|
758
|
+
if (record.http_status === undefined) throw new Error('fusion research fetch audit is missing http_status');
|
|
759
|
+
if (record.response_bytes === undefined) throw new Error('fusion research fetch audit is missing response_bytes');
|
|
760
|
+
if (record.content_sha256 === undefined) throw new Error('fusion research fetch audit is missing content_sha256');
|
|
761
|
+
} else {
|
|
762
|
+
if (record.url !== undefined || record.final_url !== undefined) {
|
|
763
|
+
throw new Error('fusion research rejected fetch audit must not persist raw URL');
|
|
764
|
+
}
|
|
765
|
+
if (record.rejected_url_sha256 === undefined) {
|
|
766
|
+
throw new Error('fusion research rejected fetch audit is missing rejected_url_sha256');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function isNotFound(error: unknown): boolean {
|
|
774
|
+
return isJsonObject(error) && error['code'] === 'ENOENT';
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function readFusionToolCallLog(path: string): Promise<FusionToolCallTrace> {
|
|
778
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
779
|
+
try {
|
|
780
|
+
handle = await open(path, constants.O_RDONLY | FUSION_PI_CHILD_O_NOFOLLOW);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
// The child extension creates this file before tools can run, so a missing file
|
|
783
|
+
// means the audit trail was never established - not that zero tools were used. Those
|
|
784
|
+
// must stay distinguishable: silently accepting absence would let a run whose activity
|
|
785
|
+
// was never recorded report success, defeating the purpose of the log.
|
|
786
|
+
if (isNotFound(error)) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`fusion tool-call log is missing at ${path}; the inspect child never initialized its audit trail`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (isJsonObject(error) && error['code'] === 'ELOOP') {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`fusion tool-call log at ${path} is a symlink; refusing to trust a redirected audit trail`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
// The audit trail must be a real file inside the run directory. A symlink here would let
|
|
800
|
+
// anything able to pre-create the path redirect the parent's read elsewhere, so the file
|
|
801
|
+
// is opened with O_NOFOLLOW and then fstat-checked before its bytes are trusted.
|
|
802
|
+
const stats = await handle.stat();
|
|
803
|
+
if (!stats.isFile()) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`fusion tool-call log at ${path} is not a regular file; refusing to trust a redirected audit trail`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
return parseFusionToolCallLog(await handle.readFile());
|
|
809
|
+
} finally {
|
|
810
|
+
await handle.close();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function assertFusionToolCallLogSeal(
|
|
815
|
+
path: string,
|
|
816
|
+
trace: FusionToolCallTrace,
|
|
817
|
+
): Promise<void> {
|
|
818
|
+
const sealPath = `${path}${FUSION_TOOL_CALL_SEAL_SUFFIX}`;
|
|
819
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
820
|
+
try {
|
|
821
|
+
handle = await open(sealPath, constants.O_RDONLY | FUSION_PI_CHILD_O_NOFOLLOW);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (isNotFound(error)) throw new Error('fusion tool-call audit completion seal is missing');
|
|
824
|
+
if (isJsonObject(error) && error['code'] === 'ELOOP') {
|
|
825
|
+
throw new Error('fusion tool-call audit completion seal is a symlink');
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
const stats = await handle.stat();
|
|
831
|
+
if (!stats.isFile()) throw new Error('fusion tool-call audit completion seal is not a regular file');
|
|
832
|
+
if (stats.size > 4096) throw new Error('fusion tool-call audit completion seal is oversized');
|
|
833
|
+
const bytes = await handle.readFile();
|
|
834
|
+
if (bytes.at(-1) !== 10) throw new Error('fusion tool-call audit completion seal is partial');
|
|
835
|
+
const text = bytes.toString('utf8');
|
|
836
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
837
|
+
throw new Error('fusion tool-call audit completion seal is not UTF-8');
|
|
838
|
+
}
|
|
839
|
+
const parsed = parseJsonText(text);
|
|
840
|
+
if (!isJsonObject(parsed) || Array.isArray(parsed)) {
|
|
841
|
+
throw new Error('fusion tool-call audit completion seal must be an object');
|
|
842
|
+
}
|
|
843
|
+
const keys = Object.keys(parsed).sort();
|
|
844
|
+
const expected = ['log_sha256', 'record_count', 'schema_version', 'status', 'total_result_bytes'];
|
|
845
|
+
if (keys.join('\0') !== expected.join('\0')) {
|
|
846
|
+
throw new Error('fusion tool-call audit completion seal keys mismatch');
|
|
847
|
+
}
|
|
848
|
+
if (parsed['schema_version'] !== FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION) {
|
|
849
|
+
throw new Error('fusion tool-call audit completion seal schema mismatch');
|
|
850
|
+
}
|
|
851
|
+
if (parsed['status'] !== 'complete') {
|
|
852
|
+
throw new Error('fusion tool-call audit completion seal reports a failed audit');
|
|
853
|
+
}
|
|
854
|
+
const recordCount = requireUsageInteger(parsed, 'record_count', 'fusion tool-call audit seal');
|
|
855
|
+
const totalResultBytes = requireUsageInteger(
|
|
856
|
+
parsed,
|
|
857
|
+
'total_result_bytes',
|
|
858
|
+
'fusion tool-call audit seal',
|
|
859
|
+
);
|
|
860
|
+
const logSha256 = requireSha256(parsed, 'log_sha256', 'fusion tool-call audit seal');
|
|
861
|
+
if (recordCount !== trace.summary.count) {
|
|
862
|
+
throw new Error('fusion tool-call audit completion seal record count mismatch');
|
|
863
|
+
}
|
|
864
|
+
if (totalResultBytes !== trace.summary.total_result_bytes) {
|
|
865
|
+
throw new Error('fusion tool-call audit completion seal result-byte total mismatch');
|
|
866
|
+
}
|
|
867
|
+
if (logSha256 !== sha256Buffer(trace.bytes)) {
|
|
868
|
+
throw new Error('fusion tool-call audit completion seal log hash mismatch');
|
|
869
|
+
}
|
|
870
|
+
if (totalResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`fusion tool-call audit exceeds aggregate result-byte limit ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
} finally {
|
|
876
|
+
await handle.close();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
374
880
|
function sha256Buffer(bytes: Buffer): string {
|
|
375
881
|
return createHash('sha256').update(bytes).digest('hex');
|
|
376
882
|
}
|
|
@@ -436,8 +942,7 @@ export class FusionPiCompactResultParser {
|
|
|
436
942
|
const final = parsed.records.at(-1);
|
|
437
943
|
if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
|
|
438
944
|
for (const record of parsed.records) this.assertModel(record);
|
|
439
|
-
|
|
440
|
-
throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
|
|
945
|
+
this.assertTranscriptStopReasons(parsed.records);
|
|
441
946
|
const observed = this.observedFromRecords(parsed.records);
|
|
442
947
|
return {
|
|
443
948
|
text: reconstructFinalText(response, final),
|
|
@@ -458,6 +963,43 @@ export class FusionPiCompactResultParser {
|
|
|
458
963
|
}
|
|
459
964
|
}
|
|
460
965
|
|
|
966
|
+
private assertTranscriptStopReasons(records: readonly FusionChildResultMetadata[]): void {
|
|
967
|
+
for (const [index, record] of records.entries()) {
|
|
968
|
+
const isFinal = index === records.length - 1;
|
|
969
|
+
if (isFinal) {
|
|
970
|
+
if (record.stop_reason !== 'stop') {
|
|
971
|
+
throw new Error(this.stopReasonError('final', 'stop', record.stop_reason, true));
|
|
972
|
+
}
|
|
973
|
+
} else if (record.stop_reason !== 'toolUse') {
|
|
974
|
+
throw new Error(
|
|
975
|
+
this.stopReasonError(`non-final record ${index}`, 'toolUse', record.stop_reason, true),
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private stopReasonError(
|
|
982
|
+
position: string,
|
|
983
|
+
expected: string,
|
|
984
|
+
observed: string,
|
|
985
|
+
includeStopDetail: boolean,
|
|
986
|
+
): string {
|
|
987
|
+
const prefix = `Pi ${position} stop reason is not ${expected}: ${observed}`;
|
|
988
|
+
if (!includeStopDetail) return prefix;
|
|
989
|
+
switch (observed) {
|
|
990
|
+
case 'length':
|
|
991
|
+
return `${prefix} (model output was truncated)`;
|
|
992
|
+
case 'error':
|
|
993
|
+
return `${prefix} (Pi reported an error stop)`;
|
|
994
|
+
case 'aborted':
|
|
995
|
+
return `${prefix} (Pi reported an aborted stop)`;
|
|
996
|
+
case 'pending':
|
|
997
|
+
return `${prefix} (Pi reported a pending stop)`;
|
|
998
|
+
default:
|
|
999
|
+
return prefix;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
461
1003
|
private observedFromRecords(
|
|
462
1004
|
records: readonly FusionChildResultMetadata[],
|
|
463
1005
|
): ObservedChildSnapshot {
|
|
@@ -542,8 +1084,8 @@ function defaultSpawn(command: string, args: string[], options: SpawnOptions): F
|
|
|
542
1084
|
/**
|
|
543
1085
|
* Termination timers must keep the event loop alive.
|
|
544
1086
|
*
|
|
545
|
-
* The SIGTERM grace, SIGKILL wait,
|
|
546
|
-
* things that settle the run promise when a child stops emitting events. An
|
|
1087
|
+
* The SIGTERM grace, SIGKILL wait, overall timeout, and idle timeout timers
|
|
1088
|
+
* are the only things that settle the run promise when a child stops emitting events. An
|
|
547
1089
|
* unref'd timer lets the loop drain first, leaving the promise pending forever
|
|
548
1090
|
* ("Promise resolution is still pending but the event loop has already
|
|
549
1091
|
* resolved"). Every timer stored here is cleared in the `finally` of
|
|
@@ -646,9 +1188,11 @@ function cleanupTimers(state: ProcessState): void {
|
|
|
646
1188
|
if (state.termTimer !== undefined) clearTimeout(state.termTimer);
|
|
647
1189
|
if (state.waitTimer !== undefined) clearTimeout(state.waitTimer);
|
|
648
1190
|
if (state.timeoutTimer !== undefined) clearTimeout(state.timeoutTimer);
|
|
1191
|
+
if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
|
|
649
1192
|
state.termTimer = undefined;
|
|
650
1193
|
state.waitTimer = undefined;
|
|
651
1194
|
state.timeoutTimer = undefined;
|
|
1195
|
+
state.idleTimer = undefined;
|
|
652
1196
|
}
|
|
653
1197
|
|
|
654
1198
|
async function writePromptToStdin(child: FusionChildProcess, prompt: string): Promise<void> {
|
|
@@ -692,16 +1236,39 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
692
1236
|
const spawnImpl = options.spawn ?? defaultSpawn;
|
|
693
1237
|
const killProcess = options.killProcess ?? process.kill.bind(process);
|
|
694
1238
|
const platform = options.platform ?? process.platform;
|
|
1239
|
+
const capability = options.capability ?? FUSION_NO_TOOLS_CAPABILITY;
|
|
695
1240
|
const env = fusionPiChildEnv(options.env ?? process.env);
|
|
1241
|
+
if (capability !== 'reason') {
|
|
1242
|
+
if (options.toolCallLogPath === undefined) {
|
|
1243
|
+
throw childError(
|
|
1244
|
+
`fusion ${capability} child requires a tool-call log path`,
|
|
1245
|
+
'orchestration_failed',
|
|
1246
|
+
options,
|
|
1247
|
+
false,
|
|
1248
|
+
false,
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
env[FUSION_TOOL_CALL_LOG_PATH_ENV] = options.toolCallLogPath;
|
|
1252
|
+
if (capability === 'research') {
|
|
1253
|
+
if (options.sourcePolicy === undefined) {
|
|
1254
|
+
throw childError('fusion research child requires a source-policy path and hash', 'orchestration_failed', options, false, false);
|
|
1255
|
+
}
|
|
1256
|
+
env[FUSION_RESEARCH_ENABLED_ENV] = '1';
|
|
1257
|
+
env[FUSION_SOURCE_POLICY_PATH_ENV] = options.sourcePolicy.path;
|
|
1258
|
+
env[FUSION_SOURCE_POLICY_SHA256_ENV] = options.sourcePolicy.sha256;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
696
1261
|
const stdoutLimit = options.stdoutLimitBytes ?? FUSION_CHILD_STDOUT_LIMIT_BYTES;
|
|
697
1262
|
const stderrLimit = options.stderrLimitBytes ?? FUSION_CHILD_STDERR_LIMIT_BYTES;
|
|
698
1263
|
const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
|
|
1264
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? FUSION_CHILD_IDLE_TIMEOUT_MS;
|
|
699
1265
|
const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
|
|
700
1266
|
const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
|
|
701
1267
|
const argv = buildFusionPiChildArgv(
|
|
702
1268
|
options.model,
|
|
703
1269
|
options.systemPrompt,
|
|
704
1270
|
options.childExtensionPath ?? resolveFusionChildExtensionPath(),
|
|
1271
|
+
capability,
|
|
705
1272
|
);
|
|
706
1273
|
const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
|
|
707
1274
|
const stdoutChunks: Buffer[] = [];
|
|
@@ -715,6 +1282,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
715
1282
|
termTimer: undefined,
|
|
716
1283
|
waitTimer: undefined,
|
|
717
1284
|
timeoutTimer: undefined,
|
|
1285
|
+
idleTimer: undefined,
|
|
718
1286
|
settled: false,
|
|
719
1287
|
};
|
|
720
1288
|
|
|
@@ -754,6 +1322,23 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
754
1322
|
};
|
|
755
1323
|
});
|
|
756
1324
|
|
|
1325
|
+
const resetIdleTimer = () => {
|
|
1326
|
+
if (state.settled) return;
|
|
1327
|
+
if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
|
|
1328
|
+
state.idleTimer = trackTimer(
|
|
1329
|
+
setTimeout(() => {
|
|
1330
|
+
if (state.settled) return;
|
|
1331
|
+
if (state.primaryError === undefined) {
|
|
1332
|
+
state.primaryError = childError(
|
|
1333
|
+
`Pi child produced no output for ${String(idleTimeoutMs)}ms (stalled)`,
|
|
1334
|
+
'child_timeout',
|
|
1335
|
+
options,
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
1339
|
+
}, idleTimeoutMs),
|
|
1340
|
+
);
|
|
1341
|
+
};
|
|
757
1342
|
const abortListener = () => {
|
|
758
1343
|
if (state.settled) return;
|
|
759
1344
|
if (state.primaryError === undefined) {
|
|
@@ -762,6 +1347,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
762
1347
|
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
763
1348
|
};
|
|
764
1349
|
const stdoutListener = (data: Buffer | string) => {
|
|
1350
|
+
resetIdleTimer();
|
|
765
1351
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
766
1352
|
const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
|
|
767
1353
|
stdoutBytes = appended.bytes;
|
|
@@ -775,6 +1361,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
775
1361
|
}
|
|
776
1362
|
};
|
|
777
1363
|
const stderrListener = (data: Buffer | string) => {
|
|
1364
|
+
resetIdleTimer();
|
|
778
1365
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
779
1366
|
const appended = appendCapped(stderrChunks, stderrBytes, chunk, stderrLimit);
|
|
780
1367
|
stderrBytes = appended.bytes;
|
|
@@ -812,6 +1399,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
812
1399
|
child.once('close', closeListener);
|
|
813
1400
|
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
814
1401
|
if (options.signal?.aborted) abortListener();
|
|
1402
|
+
resetIdleTimer();
|
|
815
1403
|
state.timeoutTimer = trackTimer(
|
|
816
1404
|
setTimeout(() => {
|
|
817
1405
|
if (state.primaryError === undefined) {
|
|
@@ -900,6 +1488,42 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
900
1488
|
observed,
|
|
901
1489
|
);
|
|
902
1490
|
}
|
|
1491
|
+
let toolCallTrace: FusionToolCallTrace | undefined;
|
|
1492
|
+
if (capability !== 'reason') {
|
|
1493
|
+
// The launch path above refuses to spawn a tool-enabled child without a log path, so
|
|
1494
|
+
// this is unreachable. Assert rather than defaulting: a `?? ''` here would silently
|
|
1495
|
+
// read an empty path if that guard were ever refactored away, turning a missing
|
|
1496
|
+
// audit trail into a successful run.
|
|
1497
|
+
const logPath = options.toolCallLogPath;
|
|
1498
|
+
if (logPath === undefined) {
|
|
1499
|
+
throw childError(
|
|
1500
|
+
`fusion ${capability} child completed without a tool-call log path`,
|
|
1501
|
+
'orchestration_failed',
|
|
1502
|
+
options,
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
toolCallTrace = await readFusionToolCallLog(logPath);
|
|
1507
|
+
await assertFusionToolCallLogSeal(logPath, toolCallTrace);
|
|
1508
|
+
await assertCompletedToolPolicy(toolCallTrace, capability, options.sourcePolicy);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
throw new FusionChildRunError(
|
|
1511
|
+
withCleanupErrors(
|
|
1512
|
+
childError(
|
|
1513
|
+
`Pi child tool-call log invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
1514
|
+
'child_event_invalid',
|
|
1515
|
+
options,
|
|
1516
|
+
),
|
|
1517
|
+
state.cleanupErrors,
|
|
1518
|
+
),
|
|
1519
|
+
compactEvents,
|
|
1520
|
+
response,
|
|
1521
|
+
diagnostics,
|
|
1522
|
+
close,
|
|
1523
|
+
observed,
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
903
1527
|
const result: FusionChildRunResult = {
|
|
904
1528
|
stage: options.stage,
|
|
905
1529
|
attempt: options.attempt,
|
|
@@ -914,6 +1538,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
914
1538
|
signal: close.signal,
|
|
915
1539
|
};
|
|
916
1540
|
if (options.slot !== undefined) result.slot = options.slot;
|
|
1541
|
+
if (toolCallTrace !== undefined) result.toolCallTrace = toolCallTrace;
|
|
917
1542
|
return result;
|
|
918
1543
|
} finally {
|
|
919
1544
|
cleanupTimers(state);
|